From ca358d09f6b54e6af70dba0ff4813466e7cc40cc Mon Sep 17 00:00:00 2001 From: Rosina Derks Date: Thu, 21 Dec 2023 14:00:24 +0100 Subject: [PATCH 01/29] Load test for writing observations into the datastore --- .github/workflows/ci.yml | 2 +- datastore/load-test/README.md | 72 +++++++++++ datastore/load-test/locustfile.py | 1 + datastore/load-test/locustfile_read.py | 72 +++++++++++ datastore/load-test/locustfile_write.py | 111 ++++++++++++++++ .../load-test/netcdf_file_to_requests.py | 118 ++++++++++++++++++ datastore/load-test/requirements.in | 1 + datastore/load-test/requirements.txt | 38 +++--- 8 files changed, 395 insertions(+), 20 deletions(-) create mode 100644 datastore/load-test/README.md create mode 100644 datastore/load-test/locustfile_read.py create mode 100644 datastore/load-test/locustfile_write.py create mode 100644 datastore/load-test/netcdf_file_to_requests.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a8004ce..eab6e1e1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: pip install -r datastore/load-test/requirements.txt python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test - locust --headless -u 5 -r 1 --run-time 60 --only-summary --csv store + locust -f locustfile_read.py --headless -u 5 -r 1 --run-time 60 --only-summary --csv store - name: Archive load test artifacts uses: actions/upload-artifact@v3 diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md new file mode 100644 index 00000000..e901521d --- /dev/null +++ b/datastore/load-test/README.md @@ -0,0 +1,72 @@ +# Load test datastore + +Locust is used for performance testing of the datastore. + +## Read test +Two tasks are defined: 1) get_data_for_single_timeserie and 2) get_data_single_station_through_bbox. As it is unclear +how many users the datastore expect, the test is done for 5 users over 60 seconds. + +## Write test + +### Setup & Data preparation +To resemble the load from all EU partner, we need to multiply KNMI data and increase the input time resolution. For this we +needed to: +* Generate dummy data from the KNMI input data by expanding the 10-minute observations to 5-sec observations. +* Insert the data on a higher temporal resolution + +Given that the load test should represent 5-min data for 5000 stations, a rate of 17 requests/sec is needed (5000/(5*60)). +A request contains the observations for all parameters for one station and one timestamp. + +Test requirements +* Runtime test = 15min (900s) +* Expected #requests in 15min = 15300 (900*17) +* wait_time between tasks = between 1.5 and 2.5 sec. Resulting in 1 requests per 2 sec per user. +* 35 users should lead to a rate of 17 request/sec, resembling EU coverage. +* User spawn rate = 1 user per sec + +### Run locust via web +```text +locust -f load-test/locustfile_write.py +``` + +### Run locust only via command line +```text +locust -f load-test/locustfile_write.py --headless -u -r --run-time --only-summary --csv store_write +``` + +### Results +Requests/sec: This is the number of completed requests per second. + +| | | | | | | +|-------|------|----------------|----------|-----------------------|-----------------------| +| Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | +| 1 | 0.5 | 428 | 0 | 110 | 110 | +| 35 | 15.2 | 13 640 | 0 | 180 | 263 | +| 55 | 17.2 | 15 439 | 0 | 790 | 1104 | + + + +### Read & Write Test +Run the read and write test together to test the load, where the write test will have 7 times more users than the read +test. This is enforced by the weight variable for both user classes. + +### Run multiple locust files via web + +```text +locust -f load-test/locustfile_write.py,load-test/locustfile_read.py +``` + +### Run multiple locust files only via command line + +```text +locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless -u -r --run-time --only-summary --csv store_write_read +``` + +| | | | | | | | +|-------|-------|------|----------------|----------|-----------------------|-----------------------| +| Test | Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | +| Write | 35 | 12.7 | 11423 | 0 | 660 | 696 | +| Read | 5 | 69.0 | 62091 | 0 | 20 | 70 | +| | | | | | | | +| Write | 53 | 14.5 | 13087 | 0 | 1500 | 1522 | +| Read | 7 | 36.4 | 32769 | 0 | 54 | 185 | diff --git a/datastore/load-test/locustfile.py b/datastore/load-test/locustfile.py index e2d46ee8..e4cc5820 100644 --- a/datastore/load-test/locustfile.py +++ b/datastore/load-test/locustfile.py @@ -33,6 +33,7 @@ class StoreGrpcUser(grpc_user.GrpcUser): host = "localhost:50050" stub_class = dstore_grpc.DatastoreStub + weight = 1 @task def get_data_for_single_timeserie(self): diff --git a/datastore/load-test/locustfile_read.py b/datastore/load-test/locustfile_read.py new file mode 100644 index 00000000..6c84f64e --- /dev/null +++ b/datastore/load-test/locustfile_read.py @@ -0,0 +1,72 @@ +# Use the following command to generate the python protobuf stuff in +# the correct place (from the root of the repository) +# python -m grpc_tools.protoc --proto_path=datastore/protobuf datastore.proto --python_out=load-test --grpc_python_out=load-test # noqa: E501 +import random +from datetime import datetime + +import datastore_pb2 as dstore +import datastore_pb2_grpc as dstore_grpc +import grpc_user +from google.protobuf.timestamp_pb2 import Timestamp +from locust import task +from shapely import buffer +from shapely import wkt + + +parameters = ["ff", "dd", "rh", "pp", "tn"] +# fmt: off +stations = [ + "06203", "06204", "06205", "06207", "06208", "06211", "06214", "06215", "06235", "06239", + "06242", "06251", "06260", "06269", "06270", "06275", "06279", "06280", "06290", "06310", + "06317", "06319", "06323", "06330", "06340", "06344", "06348", "06350", "06356", "06370", + "06375", "06380", "78871", "78873", +] +# fmt: on +points = [ + "POINT(5.179705 52.0988218)", + "POINT(3.3416666666667 52.36)", + "POINT(2.9452777777778 53.824130555556)", + "POINT(4.7811453228565 52.926865008825)", + "POINT(4.342014 51.447744494043)", +] + + +class StoreGrpcUser(grpc_user.GrpcUser): + host = "localhost:50050" + stub_class = dstore_grpc.DatastoreStub + weight = 1 + + @task + def get_data_for_single_timeserie(self): + from_time = Timestamp() + from_time.FromDatetime(datetime(2022, 12, 31)) + to_time = Timestamp() + to_time.FromDatetime(datetime(2023, 1, 1)) + + request = dstore.GetObsRequest( + interval=dstore.TimeInterval(start=from_time, end=to_time), + platforms=[random.choice(stations)], + instruments=[random.choice(parameters)], + ) + response = self.stub.GetObservations(request) + assert len(response.observations) == 1 + assert len(response.observations[0].obs_mdata) == 144 + + @task + def get_data_single_station_through_bbox(self): + from_time = Timestamp() + from_time.FromDatetime(datetime(2022, 12, 31)) + to_time = Timestamp() + to_time.FromDatetime(datetime(2023, 1, 1)) + + point = wkt.loads(random.choice(points)) + poly = buffer(point, 0.0001, quad_segs=1) # Roughly 10 meters around the point + + request = dstore.GetObsRequest( + interval=dstore.TimeInterval(start=from_time, end=to_time), + instruments=[random.choice(parameters)], + inside=dstore.Polygon(points=[dstore.Point(lat=coord[1], lon=coord[0]) for coord in poly.exterior.coords]), + ) + response = self.stub.GetObservations(request) + assert len(response.observations) == 1 + assert len(response.observations[0].obs_mdata) == 144 diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py new file mode 100644 index 00000000..ef7c15ea --- /dev/null +++ b/datastore/load-test/locustfile_write.py @@ -0,0 +1,111 @@ +from pathlib import Path + +import datastore_pb2 as dstore +import datastore_pb2_grpc as dstore_grpc +import grpc_user +import psycopg2 +from locust import between +from locust import events +from locust import task +from netcdf_file_to_requests import generate_dummy_requests_from_netcdf_per_station_per_timestamp + + +file_path = Path(Path(__file__).parents[1] / "test-data" / "KNMI" / "20230101.nc") + +stations = [ + "06201", + "06203", + "06204", + "06205", + "06207", + "06208", + "06211", + "06214", + "06215", + "06225", + "06229", + "06235", + "06239", + "06240", + "06242", + "06248", + "06249", + "06251", + "06252", + "06257", + "06258", + "06260", + "06267", + "06269", + "06270", + "06273", + "06275", + "06277", + "06278", + "06279", + "06280", + "06283", + "06286", + "06290", + "06310", + "06317", + "06319", + "06320", + "06321", + "06323", + "06330", + "06340", + "06343", + "06344", + "06348", + "06350", + "06356", + "06370", + "06375", + "06377", + "06380", + "06391", + "78871", + "78873", + "78990", +] + + +class IngestionGrpcUser(grpc_user.GrpcUser): + host = "localhost:50050" + stub_class = dstore_grpc.DatastoreStub + wait_time = between(1.5, 2.5) + user_nr = 0 + dummy_observations_all_stations = generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path) + weight = 7 + + def on_start(self): + print(f"User {IngestionGrpcUser.user_nr}") + self.dummy_observations_per_station = IngestionGrpcUser.dummy_observations_all_stations[ + IngestionGrpcUser.user_nr + ] + IngestionGrpcUser.user_nr += 1 + self.index = 0 + + @task + def ingest_data_per_observation(self): + # 44 observations per task + observations = self.dummy_observations_per_station[self.index]["observations"] + request_messages = dstore.PutObsRequest(observations=observations) + response = self.stub.PutObservations(request_messages) + assert response.status == -1 + self.index += 1 + + @events.test_stop.add_listener + def on_test_stop(environment, **kwargs): + print("Cleaning up test data") + conn = psycopg2.connect( + database="data", user="postgres", password="mysecretpassword", host="localhost", port="5433" + ) + cursor = conn.cursor() + # delete all details from observations table for date 20230101 + sql = """ DELETE FROM observation WHERE extract(YEAR from obstime_instant)::int = 2023 """ + cursor.execute(sql) + # Commit your changes in the database + conn.commit() + conn.close() diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py new file mode 100644 index 00000000..4e5d13c0 --- /dev/null +++ b/datastore/load-test/netcdf_file_to_requests.py @@ -0,0 +1,118 @@ +import math +import uuid +from datetime import datetime +from datetime import timedelta +from pathlib import Path +from time import perf_counter +from typing import List +from typing import Tuple + +import datastore_pb2 as dstore +import pandas as pd +import xarray as xr +from google.protobuf.timestamp_pb2 import Timestamp + + +knmi_parameter_names = ( + "hc3", + "nc2", + "zm", + "R1H", + "hc", + "tgn", + "Tn12", + "pr", + "pg", + "tn", + "rg", + "hc1", + "nc1", + "ts1", + "nc3", + "ts2", + "qg", + "ff", + "ww", + "gff", + "dd", + "td", + "ww-10", + "Tgn12", + "ss", + "Tn6", + "dr", + "rh", + "hc2", + "Tgn6", + "R12H", + "R24H", + "Tx6", + "Tx24", + "Tx12", + "Tgn14", + "D1H", + "R6H", + "pwc", + "tx", + "nc", + "pp", + "Tn14", + "ta", +) + + +def timerange(start_time, end_time, interval_minutes): + current_time = start_time + while current_time < end_time: + yield current_time + current_time += timedelta(minutes=interval_minutes) + + +def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Path | str) -> Tuple[List, List]: + print("Starting with creating the time series and observations requests.") + create_requests_start = perf_counter() + obs_per_station = [] + + with xr.open_dataset(file_path, engine="netcdf4", chunks=None) as file: # chunks=None to disable dask + # Slice enough timestamps to generate data for the test to run 15 min + time_slice = file.sel(time=slice(datetime(2023, 1, 1, 0, 0, 0), datetime(2023, 1, 1, 1, 1, 0))) + for station_id, latitude, longitude, height in zip( + time_slice["station"].values, + time_slice["lat"].values[0], + time_slice["lon"].values[0], + time_slice["height"].values[0], + ): + station_slice = time_slice.sel(station=station_id) + obs_per_timestamp = [] + for time in pd.to_datetime(station_slice["time"].data).to_pydatetime(): + # Generate 5-sec data from each 10-min observation + for i in range(0, 600, 5): # 5-sec data + obs_per_parameter = [] + generated_timestamp = time + timedelta(seconds=i) + ts = Timestamp() + ts.FromDatetime(generated_timestamp) + for param_id in knmi_parameter_names: + param = station_slice[param_id] + obs_value = station_slice[param_id].data[0] + obs_value = 0 if math.isnan(obs_value) else obs_value # dummy data so obs_value doesn't matter + ts_mdata = dstore.TSMetadata( + platform=station_id, + instrument=param_id, + title=param.long_name, + standard_name=param.standard_name if "standard_name" in param.attrs else None, + unit=param.units if "units" in param.attrs else None, + ) + obs_mdata = dstore.ObsMetadata( + id=str(uuid.uuid4()), + geo_point=dstore.Point(lat=latitude, lon=longitude), + obstime_instant=ts, + value=str(obs_value), + ) + observation = dstore.Metadata1(ts_mdata=ts_mdata, obs_mdata=obs_mdata) + obs_per_parameter.append(observation) + obs_per_timestamp.append({"time": generated_timestamp, "observations": obs_per_parameter}) + obs_per_station.append(obs_per_timestamp) + + print("Finished creating the time series and observation requests " f"{perf_counter() - create_requests_start}.") + print(f"Total number of obs generated per station is {len(obs_per_parameter)*len(obs_per_timestamp)}") + return obs_per_station diff --git a/datastore/load-test/requirements.in b/datastore/load-test/requirements.in index ad8ddda2..e81bf9ca 100644 --- a/datastore/load-test/requirements.in +++ b/datastore/load-test/requirements.in @@ -7,3 +7,4 @@ grpcio-tools~=1.56 grpc-interceptor~=0.15.3 locust~=2.16 shapely~=2.0 +psycopg2~=2.9 diff --git a/datastore/load-test/requirements.txt b/datastore/load-test/requirements.txt index a3c3d7fd..51600fd2 100644 --- a/datastore/load-test/requirements.txt +++ b/datastore/load-test/requirements.txt @@ -1,18 +1,18 @@ # -# This file is autogenerated by pip-compile with Python 3.11 +# This file is autogenerated by pip-compile with Python 3.10 # by the following command: # # pip-compile --no-emit-index-url # -blinker==1.6.3 +blinker==1.7.0 # via flask brotli==1.1.0 # via geventhttpclient -certifi==2023.7.22 +certifi==2023.11.17 # via # geventhttpclient # requests -charset-normalizer==3.3.0 +charset-normalizer==3.3.2 # via requests click==8.1.7 # via flask @@ -33,23 +33,23 @@ gevent==23.9.1 # locust geventhttpclient==2.0.11 # via locust -greenlet==3.0.0 +greenlet==3.0.2 # via gevent -grpc-interceptor==0.15.3 +grpc-interceptor==0.15.4 # via -r requirements.in -grpcio==1.59.0 +grpcio==1.60.0 # via # grpc-interceptor # grpcio-tools -grpcio-tools==1.59.0 +grpcio-tools==1.60.0 # via -r requirements.in -idna==3.4 +idna==3.6 # via requests itsdangerous==2.1.2 # via flask jinja2==3.1.2 # via flask -locust==2.17.0 +locust==2.20.0 # via -r requirements.in markupsafe==2.1.3 # via @@ -57,27 +57,27 @@ markupsafe==2.1.3 # werkzeug msgpack==1.0.7 # via locust -numpy==1.26.0 +numpy==1.26.2 # via shapely -protobuf==4.24.4 +protobuf==4.25.1 # via grpcio-tools -psutil==5.9.5 +psutil==5.9.7 # via locust -pyzmq==25.1.1 +psycopg2==2.9.9 + # via -r requirements.in +pyzmq==25.1.2 # via locust requests==2.31.0 # via locust roundrobin==0.0.4 # via locust -shapely==2.0.1 +shapely==2.0.2 # via -r requirements.in six==1.16.0 # via geventhttpclient -typing-extensions==4.8.0 - # via locust -urllib3==2.0.6 +urllib3==2.1.0 # via requests -werkzeug==3.0.0 +werkzeug==3.0.1 # via # flask # locust From e00c55c4a26dcfd8a315e77ca1014f1fd23957fb Mon Sep 17 00:00:00 2001 From: Rosina Derks Date: Thu, 21 Dec 2023 14:06:19 +0100 Subject: [PATCH 02/29] Fix pre-commit complaint and improve naming --- datastore/load-test/locustfile_write.py | 4 ++-- datastore/load-test/netcdf_file_to_requests.py | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index ef7c15ea..d1c75202 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -88,8 +88,8 @@ def on_start(self): self.index = 0 @task - def ingest_data_per_observation(self): - # 44 observations per task + def ingest_data_per_timestamp_per_station(self): + # 44 observations (parameters) per task observations = self.dummy_observations_per_station[self.index]["observations"] request_messages = dstore.PutObsRequest(observations=observations) response = self.stub.PutObservations(request_messages) diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py index 4e5d13c0..eecda8a0 100644 --- a/datastore/load-test/netcdf_file_to_requests.py +++ b/datastore/load-test/netcdf_file_to_requests.py @@ -114,5 +114,5 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat obs_per_station.append(obs_per_timestamp) print("Finished creating the time series and observation requests " f"{perf_counter() - create_requests_start}.") - print(f"Total number of obs generated per station is {len(obs_per_parameter)*len(obs_per_timestamp)}") + print(f"Total number of obs generated per station is {len(obs_per_parameter) * len(obs_per_timestamp)}") return obs_per_station From b7de3255d6eff622d78d2c1301a90387727e051a Mon Sep 17 00:00:00 2001 From: Rosina Derks Date: Fri, 22 Dec 2023 16:36:59 +0100 Subject: [PATCH 03/29] Simplefy data generation for load testing and expand README --- datastore/load-test/README.md | 53 +++++++++++++----- .../load-test/netcdf_file_to_requests.py | 16 +++--- .../response_times_(ms)_1703258125.png | Bin 0 -> 35679 bytes .../total_requests_per_second_1703258125.png | Bin 0 -> 30097 bytes 4 files changed, 45 insertions(+), 24 deletions(-) create mode 100644 load-test/docs_images/response_times_(ms)_1703258125.png create mode 100644 load-test/docs_images/total_requests_per_second_1703258125.png diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md index e901521d..91ed1df0 100644 --- a/datastore/load-test/README.md +++ b/datastore/load-test/README.md @@ -1,21 +1,28 @@ # Load test datastore -Locust is used for performance testing of the datastore. +Locust is used for performance testing of the datastore. Tests are done on a Macbook M1 Pro (32gb) with +Docker settings 2 CPUs and 6 GB memory. ## Read test Two tasks are defined: 1) get_data_for_single_timeserie and 2) get_data_single_station_through_bbox. As it is unclear -how many users the datastore expect, the test is done for 5 users over 60 seconds. +how many users the datastore expect, the test is done for 5 users over 60 seconds in the ci. +A example run of the test in the ci is shown in the table below for 60 sec runtime. + +| | | | | | | +|-------|-------|----------------|----------|-----------------------|-----------------------| +| Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | +| 5 | 366.5 | 21.282 | 0 | 9 | 12.9 | ## Write test ### Setup & Data preparation To resemble the load from all EU partner, we need to multiply KNMI data and increase the input time resolution. For this we -needed to: -* Generate dummy data from the KNMI input data by expanding the 10-minute observations to 5-sec observations. -* Insert the data on a higher temporal resolution +need to: +* Generate dummy data from the KNMI input data by expanding the 10-minute observations to 100-sec observations. +* Insert the data on a higher temporal resolution. -Given that the load test should represent 5-min data for 5000 stations, a rate of 17 requests/sec is needed (5000/(5*60)). -A request contains the observations for all parameters for one station and one timestamp. +Given that the load test should represent 5-min data for 5000 stations, a rate of 17 requests/sec is needed (12*5000/3600). +A (PutObs) request contains the observations for all parameters for one station and one timestamp. Test requirements * Runtime test = 15min (900s) @@ -37,16 +44,16 @@ locust -f load-test/locustfile_write.py --headless -u -r -- ### Results Requests/sec: This is the number of completed requests per second. -| | | | | | | -|-------|------|----------------|----------|-----------------------|-----------------------| -| Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | -| 1 | 0.5 | 428 | 0 | 110 | 110 | -| 35 | 15.2 | 13 640 | 0 | 180 | 263 | -| 55 | 17.2 | 15 439 | 0 | 790 | 1104 | - +| | | | | | | +|---------------------------------|------|----------------|----------|-----------------------|-----------------------| +| Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | +| 1 | 0.5 | 428 | 0 | 110 | 110 | +| 35 | 15.2 | 13 640 | 0 | 180 | 263 | +| 55 | 17.2 | 15 439 | 0 | 790 | 1104 | +| 1 (no wait_time, runtime 1 min) | 15.3 | 864 | 0 | 64 | 65 | -### Read & Write Test +## Read & Write Test Run the read and write test together to test the load, where the write test will have 7 times more users than the read test. This is enforced by the weight variable for both user classes. @@ -70,3 +77,19 @@ locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless | | | | | | | | | Write | 53 | 14.5 | 13087 | 0 | 1500 | 1522 | | Read | 7 | 36.4 | 32769 | 0 | 54 | 185 | + + +#### Chart for users(35,5) test: +Total Requests per Second +![Total Requests per Second (write+read)](docs_images/total_requests_per_second_1703258125.png "Total Requests per Second (write+read)") +Total Response Times (ms) +![Total Response Times (ms)](docs_images/response_times_(ms)_1703258125.png "Total Response Times (ms)") + +## Rerun tests +run-time here is 180s instead of 900s. +```text +locust -f load-test/locustfile_write.py --headless -u 1 -r 1 --run-time 180 --only-summary \ +&& locust -f load-test/locustfile_write.py --headless -u 35 -r 1 --run-time 180 --only-summary \ +&& locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless -u 40 -r 1 --run-time 180 --only-summary + +``` \ No newline at end of file diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py index eecda8a0..572ac024 100644 --- a/datastore/load-test/netcdf_file_to_requests.py +++ b/datastore/load-test/netcdf_file_to_requests.py @@ -74,19 +74,17 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat obs_per_station = [] with xr.open_dataset(file_path, engine="netcdf4", chunks=None) as file: # chunks=None to disable dask - # Slice enough timestamps to generate data for the test to run 15 min - time_slice = file.sel(time=slice(datetime(2023, 1, 1, 0, 0, 0), datetime(2023, 1, 1, 1, 1, 0))) for station_id, latitude, longitude, height in zip( - time_slice["station"].values, - time_slice["lat"].values[0], - time_slice["lon"].values[0], - time_slice["height"].values[0], + file["station"].values, + file["lat"].values[0], + file["lon"].values[0], + file["height"].values[0], ): - station_slice = time_slice.sel(station=station_id) + station_slice = file.sel(station=station_id) obs_per_timestamp = [] for time in pd.to_datetime(station_slice["time"].data).to_pydatetime(): - # Generate 5-sec data from each 10-min observation - for i in range(0, 600, 5): # 5-sec data + # Generate 100-sec data from each 10-min observation + for i in range(0, 600, 100): # 100-sec data obs_per_parameter = [] generated_timestamp = time + timedelta(seconds=i) ts = Timestamp() diff --git a/load-test/docs_images/response_times_(ms)_1703258125.png b/load-test/docs_images/response_times_(ms)_1703258125.png new file mode 100644 index 0000000000000000000000000000000000000000..2460d4cddc4707c055456ab3e1ba37706888afaa GIT binary patch literal 35679 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

~6-nmeq0OB9!#MGo0`W$|`F*lTbDPiX6(Q;HLF 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 C21 Date: Fri, 22 Dec 2023 16:38:35 +0100 Subject: [PATCH 04/29] Simplefy data generation for load testing and expand README --- datastore/load-test/README.md | 6 +++--- datastore/load-test/netcdf_file_to_requests.py | 9 ++++----- 2 files changed, 7 insertions(+), 8 deletions(-) diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md index 91ed1df0..b903876a 100644 --- a/datastore/load-test/README.md +++ b/datastore/load-test/README.md @@ -1,11 +1,11 @@ # Load test datastore -Locust is used for performance testing of the datastore. Tests are done on a Macbook M1 Pro (32gb) with +Locust is used for performance testing of the datastore. Tests are done on a Macbook M1 Pro (32gb) with Docker settings 2 CPUs and 6 GB memory. ## Read test Two tasks are defined: 1) get_data_for_single_timeserie and 2) get_data_single_station_through_bbox. As it is unclear -how many users the datastore expect, the test is done for 5 users over 60 seconds in the ci. +how many users the datastore expect, the test is done for 5 users over 60 seconds in the ci. A example run of the test in the ci is shown in the table below for 60 sec runtime. | | | | | | | @@ -92,4 +92,4 @@ locust -f load-test/locustfile_write.py --headless -u 1 -r 1 --run-time 180 --on && locust -f load-test/locustfile_write.py --headless -u 35 -r 1 --run-time 180 --only-summary \ && locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless -u 40 -r 1 --run-time 180 --only-summary -``` \ No newline at end of file +``` diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py index 572ac024..3d21f1ed 100644 --- a/datastore/load-test/netcdf_file_to_requests.py +++ b/datastore/load-test/netcdf_file_to_requests.py @@ -1,6 +1,5 @@ import math import uuid -from datetime import datetime from datetime import timedelta from pathlib import Path from time import perf_counter @@ -75,10 +74,10 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat with xr.open_dataset(file_path, engine="netcdf4", chunks=None) as file: # chunks=None to disable dask for station_id, latitude, longitude, height in zip( - file["station"].values, - file["lat"].values[0], - file["lon"].values[0], - file["height"].values[0], + file["station"].values, + file["lat"].values[0], + file["lon"].values[0], + file["height"].values[0], ): station_slice = file.sel(station=station_id) obs_per_timestamp = [] From 09d49cb980e6058d918a1087792f0b36f4ac55cf Mon Sep 17 00:00:00 2001 From: Jeffrey Vervoort Date: Fri, 5 Jan 2024 14:57:09 +0100 Subject: [PATCH 05/29] Update the requirements so that I can run the load tests. --- datastore/load-test/requirements.in | 5 +++- datastore/load-test/requirements.txt | 38 +++++++++++++++++++++++----- 2 files changed, 36 insertions(+), 7 deletions(-) diff --git a/datastore/load-test/requirements.in b/datastore/load-test/requirements.in index e81bf9ca..d3bf4d20 100644 --- a/datastore/load-test/requirements.in +++ b/datastore/load-test/requirements.in @@ -6,5 +6,8 @@ grpcio-tools~=1.56 grpc-interceptor~=0.15.3 locust~=2.16 +netCDF4~=1.6 shapely~=2.0 -psycopg2~=2.9 +pandas~=2.1 +psycopg2-binary~=2.9 +xarray~=2023.12 diff --git a/datastore/load-test/requirements.txt b/datastore/load-test/requirements.txt index 51600fd2..2982d099 100644 --- a/datastore/load-test/requirements.txt +++ b/datastore/load-test/requirements.txt @@ -11,7 +11,10 @@ brotli==1.1.0 certifi==2023.11.17 # via # geventhttpclient + # netcdf4 # requests +cftime==1.6.3 + # via netcdf4 charset-normalizer==3.3.2 # via requests click==8.1.7 @@ -33,7 +36,7 @@ gevent==23.9.1 # locust geventhttpclient==2.0.11 # via locust -greenlet==3.0.2 +greenlet==3.0.3 # via gevent grpc-interceptor==0.15.4 # via -r requirements.in @@ -49,7 +52,7 @@ itsdangerous==2.1.2 # via flask jinja2==3.1.2 # via flask -locust==2.20.0 +locust==2.20.1 # via -r requirements.in markupsafe==2.1.3 # via @@ -57,14 +60,31 @@ markupsafe==2.1.3 # werkzeug msgpack==1.0.7 # via locust -numpy==1.26.2 - # via shapely +netcdf4==1.6.5 + # via -r requirements.in +numpy==1.26.3 + # via + # cftime + # netcdf4 + # pandas + # shapely + # xarray +packaging==23.2 + # via xarray +pandas==2.1.4 + # via + # -r requirements.in + # xarray protobuf==4.25.1 # via grpcio-tools psutil==5.9.7 # via locust -psycopg2==2.9.9 +psycopg2-binary==2.9.9 # via -r requirements.in +python-dateutil==2.8.2 + # via pandas +pytz==2023.3.post1 + # via pandas pyzmq==25.1.2 # via locust requests==2.31.0 @@ -74,13 +94,19 @@ roundrobin==0.0.4 shapely==2.0.2 # via -r requirements.in six==1.16.0 - # via geventhttpclient + # via + # geventhttpclient + # python-dateutil +tzdata==2023.4 + # via pandas urllib3==2.1.0 # via requests werkzeug==3.0.1 # via # flask # locust +xarray==2023.12.0 + # via -r requirements.in zope-event==5.0 # via gevent zope-interface==6.1 From 86acae67cda9b862fbe9c404e662986a58c07d14 Mon Sep 17 00:00:00 2001 From: Jeffrey Vervoort Date: Fri, 5 Jan 2024 15:46:12 +0100 Subject: [PATCH 06/29] Update the Locust command in github ci to test read and write. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eab6e1e1..cdc93c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,7 @@ jobs: pip install -r datastore/load-test/requirements.txt python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test - locust -f locustfile_read.py --headless -u 5 -r 1 --run-time 60 --only-summary --csv store + locust -f locustfile_write.py,locustfile_read.py --headless -u 5 -r 1 --run-time 300 --only-summary --csv store - name: Archive load test artifacts uses: actions/upload-artifact@v3 From 3a7f49e7e938f4d5d4a32993046a978c066bb010 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 9 Jan 2024 12:08:38 +0100 Subject: [PATCH 07/29] Don't use same value for all time points. --- datastore/load-test/locustfile_write.py | 119 +++++++++--------- .../load-test/netcdf_file_to_requests.py | 4 +- 2 files changed, 62 insertions(+), 61 deletions(-) diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index d1c75202..d71202f7 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -12,63 +12,63 @@ file_path = Path(Path(__file__).parents[1] / "test-data" / "KNMI" / "20230101.nc") -stations = [ - "06201", - "06203", - "06204", - "06205", - "06207", - "06208", - "06211", - "06214", - "06215", - "06225", - "06229", - "06235", - "06239", - "06240", - "06242", - "06248", - "06249", - "06251", - "06252", - "06257", - "06258", - "06260", - "06267", - "06269", - "06270", - "06273", - "06275", - "06277", - "06278", - "06279", - "06280", - "06283", - "06286", - "06290", - "06310", - "06317", - "06319", - "06320", - "06321", - "06323", - "06330", - "06340", - "06343", - "06344", - "06348", - "06350", - "06356", - "06370", - "06375", - "06377", - "06380", - "06391", - "78871", - "78873", - "78990", -] +# stations = [ +# "06201", +# "06203", +# "06204", +# "06205", +# "06207", +# "06208", +# "06211", +# "06214", +# "06215", +# "06225", +# "06229", +# "06235", +# "06239", +# "06240", +# "06242", +# "06248", +# "06249", +# "06251", +# "06252", +# "06257", +# "06258", +# "06260", +# "06267", +# "06269", +# "06270", +# "06273", +# "06275", +# "06277", +# "06278", +# "06279", +# "06280", +# "06283", +# "06286", +# "06290", +# "06310", +# "06317", +# "06319", +# "06320", +# "06321", +# "06323", +# "06330", +# "06340", +# "06343", +# "06344", +# "06348", +# "06350", +# "06356", +# "06370", +# "06375", +# "06377", +# "06380", +# "06391", +# "78871", +# "78873", +# "78990", +# ] class IngestionGrpcUser(grpc_user.GrpcUser): @@ -97,8 +97,8 @@ def ingest_data_per_timestamp_per_station(self): self.index += 1 @events.test_stop.add_listener - def on_test_stop(environment, **kwargs): - print("Cleaning up test data") + def on_test_stop(environment, **kwargs): # noqa: N805 + print("Cleaning up test data...") conn = psycopg2.connect( database="data", user="postgres", password="mysecretpassword", host="localhost", port="5433" ) @@ -109,3 +109,4 @@ def on_test_stop(environment, **kwargs): # Commit your changes in the database conn.commit() conn.close() + print("Done cleaning up test data") diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py index 3d21f1ed..de2b3351 100644 --- a/datastore/load-test/netcdf_file_to_requests.py +++ b/datastore/load-test/netcdf_file_to_requests.py @@ -81,7 +81,7 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat ): station_slice = file.sel(station=station_id) obs_per_timestamp = [] - for time in pd.to_datetime(station_slice["time"].data).to_pydatetime(): + for idx, time in enumerate(pd.to_datetime(station_slice["time"].data).to_pydatetime()): # Generate 100-sec data from each 10-min observation for i in range(0, 600, 100): # 100-sec data obs_per_parameter = [] @@ -90,7 +90,7 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat ts.FromDatetime(generated_timestamp) for param_id in knmi_parameter_names: param = station_slice[param_id] - obs_value = station_slice[param_id].data[0] + obs_value = station_slice[param_id].data[idx] # Use 10 minute data value for each obs_value = 0 if math.isnan(obs_value) else obs_value # dummy data so obs_value doesn't matter ts_mdata = dstore.TSMetadata( platform=station_id, From c2c7d7f21a0cab51314329494d26016cde910301 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 9 Jan 2024 14:40:03 +0100 Subject: [PATCH 08/29] Load test support for setting up message "per variable". --- datastore/load-test/locustfile_write.py | 5 ++++- datastore/load-test/netcdf_file_to_requests.py | 13 ++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index d71202f7..cab1969c 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -76,7 +76,7 @@ class IngestionGrpcUser(grpc_user.GrpcUser): stub_class = dstore_grpc.DatastoreStub wait_time = between(1.5, 2.5) user_nr = 0 - dummy_observations_all_stations = generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path) + dummy_observations_all_stations = generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path, False) weight = 7 def on_start(self): @@ -84,6 +84,9 @@ def on_start(self): self.dummy_observations_per_station = IngestionGrpcUser.dummy_observations_all_stations[ IngestionGrpcUser.user_nr ] + print(f"Number of messages to send: {len(self.dummy_observations_per_station)}") + print(f'Number of observations in first: {len(self.dummy_observations_per_station[0]["observations"])}') + print(f'Number of observations in last: {len(self.dummy_observations_per_station[-1]["observations"])}') IngestionGrpcUser.user_nr += 1 self.index = 0 diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py index de2b3351..831262ce 100644 --- a/datastore/load-test/netcdf_file_to_requests.py +++ b/datastore/load-test/netcdf_file_to_requests.py @@ -67,7 +67,9 @@ def timerange(start_time, end_time, interval_minutes): current_time += timedelta(minutes=interval_minutes) -def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Path | str) -> Tuple[List, List]: +def generate_dummy_requests_from_netcdf_per_station_per_timestamp( + file_path: Path | str, per_variable: bool = False +) -> Tuple[List, List]: print("Starting with creating the time series and observations requests.") create_requests_start = perf_counter() obs_per_station = [] @@ -90,7 +92,7 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat ts.FromDatetime(generated_timestamp) for param_id in knmi_parameter_names: param = station_slice[param_id] - obs_value = station_slice[param_id].data[idx] # Use 10 minute data value for each + obs_value = station_slice[param_id].data[idx] # Use 10-minute data value for each obs_value = 0 if math.isnan(obs_value) else obs_value # dummy data so obs_value doesn't matter ts_mdata = dstore.TSMetadata( platform=station_id, @@ -107,7 +109,12 @@ def generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path: Pat ) observation = dstore.Metadata1(ts_mdata=ts_mdata, obs_mdata=obs_mdata) obs_per_parameter.append(observation) - obs_per_timestamp.append({"time": generated_timestamp, "observations": obs_per_parameter}) + if per_variable: + obs_per_timestamp.append({"time": generated_timestamp, "observations": obs_per_parameter}) + obs_per_parameter = [] + + if not per_variable: + obs_per_timestamp.append({"time": generated_timestamp, "observations": obs_per_parameter}) obs_per_station.append(obs_per_timestamp) print("Finished creating the time series and observation requests " f"{perf_counter() - create_requests_start}.") From 4dd93202277b920a897a0451d38c17c58603474f Mon Sep 17 00:00:00 2001 From: Jo Asplin Date: Wed, 10 Jan 2024 15:38:51 +0100 Subject: [PATCH 09/29] Moved load-test images --- .../docs_images/response_times_(ms)_1703258125.png | Bin .../total_requests_per_second_1703258125.png | Bin 2 files changed, 0 insertions(+), 0 deletions(-) rename {load-test => datastore/load-test}/docs_images/response_times_(ms)_1703258125.png (100%) rename {load-test => datastore/load-test}/docs_images/total_requests_per_second_1703258125.png (100%) diff --git a/load-test/docs_images/response_times_(ms)_1703258125.png b/datastore/load-test/docs_images/response_times_(ms)_1703258125.png similarity index 100% rename from load-test/docs_images/response_times_(ms)_1703258125.png rename to datastore/load-test/docs_images/response_times_(ms)_1703258125.png diff --git a/load-test/docs_images/total_requests_per_second_1703258125.png b/datastore/load-test/docs_images/total_requests_per_second_1703258125.png similarity index 100% rename from load-test/docs_images/total_requests_per_second_1703258125.png rename to datastore/load-test/docs_images/total_requests_per_second_1703258125.png From 92c75c2867c1b5594c6207102aff1194dcddf6ac Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 16 Jan 2024 13:38:52 +0100 Subject: [PATCH 10/29] Fix data path. --- datastore/load-test/locustfile_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index cab1969c..68b7c8fa 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -10,7 +10,7 @@ from netcdf_file_to_requests import generate_dummy_requests_from_netcdf_per_station_per_timestamp -file_path = Path(Path(__file__).parents[1] / "test-data" / "KNMI" / "20230101.nc") +file_path = Path(Path(__file__).parents[1] / "data-loader" / "test-data" / "KNMI" / "20230101.nc") # stations = [ # "06201", From 8108b9b6ec1e0138a80c58232fb71a2ddca81b86 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 16 Jan 2024 14:14:37 +0100 Subject: [PATCH 11/29] Debugging... --- datastore/load-test/locustfile_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index 68b7c8fa..1b3910d2 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -9,7 +9,7 @@ from locust import task from netcdf_file_to_requests import generate_dummy_requests_from_netcdf_per_station_per_timestamp - +print(Path(__file__)) file_path = Path(Path(__file__).parents[1] / "data-loader" / "test-data" / "KNMI" / "20230101.nc") # stations = [ From d2a711b3f0cfe88c3169790a0e70556e8d95b967 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 16 Jan 2024 14:20:47 +0100 Subject: [PATCH 12/29] Try to fix path. Also regenerate requirements.txt with Python 3.11. --- datastore/load-test/locustfile_write.py | 4 ++-- datastore/load-test/requirements.txt | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index 1b3910d2..7edbd814 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -9,8 +9,8 @@ from locust import task from netcdf_file_to_requests import generate_dummy_requests_from_netcdf_per_station_per_timestamp -print(Path(__file__)) -file_path = Path(Path(__file__).parents[1] / "data-loader" / "test-data" / "KNMI" / "20230101.nc") +print(Path(__file__), flush=True) +file_path = Path(Path(__file__).parent.parent / "data-loader" / "test-data" / "KNMI" / "20230101.nc") # stations = [ # "06201", diff --git a/datastore/load-test/requirements.txt b/datastore/load-test/requirements.txt index 2982d099..93a34055 100644 --- a/datastore/load-test/requirements.txt +++ b/datastore/load-test/requirements.txt @@ -1,5 +1,5 @@ # -# This file is autogenerated by pip-compile with Python 3.10 +# This file is autogenerated by pip-compile with Python 3.11 # by the following command: # # pip-compile --no-emit-index-url @@ -50,7 +50,7 @@ idna==3.6 # via requests itsdangerous==2.1.2 # via flask -jinja2==3.1.2 +jinja2==3.1.3 # via flask locust==2.20.1 # via -r requirements.in @@ -75,7 +75,7 @@ pandas==2.1.4 # via # -r requirements.in # xarray -protobuf==4.25.1 +protobuf==4.25.2 # via grpcio-tools psutil==5.9.7 # via locust From 7b47e28854a2ab7dbc6b24596bbc490fb0e76961 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 16 Jan 2024 14:39:11 +0100 Subject: [PATCH 13/29] Fix path (again). --- .github/workflows/ci.yml | 1 + datastore/load-test/locustfile_write.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc93c7a..6b402b52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,6 +64,7 @@ jobs: pip install -r datastore/load-test/requirements.txt python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test + pwd locust -f locustfile_write.py,locustfile_read.py --headless -u 5 -r 1 --run-time 300 --only-summary --csv store - name: Archive load test artifacts diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py index 7edbd814..769a569b 100644 --- a/datastore/load-test/locustfile_write.py +++ b/datastore/load-test/locustfile_write.py @@ -9,8 +9,7 @@ from locust import task from netcdf_file_to_requests import generate_dummy_requests_from_netcdf_per_station_per_timestamp -print(Path(__file__), flush=True) -file_path = Path(Path(__file__).parent.parent / "data-loader" / "test-data" / "KNMI" / "20230101.nc") +file_path = Path(Path(__file__).resolve().parents[1] / "data-loader" / "test-data" / "KNMI" / "20230101.nc") # stations = [ # "06201", From 027536032b0fa97020dc21f140f8defdeb062fa0 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 16 Jan 2024 17:05:29 +0100 Subject: [PATCH 14/29] Add scheduler based data writer (load test). --- .github/workflows/ci.yml | 1 - datastore/load-test/requirements.in | 1 + datastore/load-test/requirements.txt | 9 ++- datastore/load-test/schedule_write.py | 102 ++++++++++++++++++++++++++ datastore/load-test/variables.py | 46 ++++++++++++ 5 files changed, 157 insertions(+), 2 deletions(-) create mode 100644 datastore/load-test/schedule_write.py create mode 100644 datastore/load-test/variables.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6b402b52..cdc93c7a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -64,7 +64,6 @@ jobs: pip install -r datastore/load-test/requirements.txt python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test - pwd locust -f locustfile_write.py,locustfile_read.py --headless -u 5 -r 1 --run-time 300 --only-summary --csv store - name: Archive load test artifacts diff --git a/datastore/load-test/requirements.in b/datastore/load-test/requirements.in index d3bf4d20..67d06b0e 100644 --- a/datastore/load-test/requirements.in +++ b/datastore/load-test/requirements.in @@ -11,3 +11,4 @@ shapely~=2.0 pandas~=2.1 psycopg2-binary~=2.9 xarray~=2023.12 +apscheduler~=3.10 diff --git a/datastore/load-test/requirements.txt b/datastore/load-test/requirements.txt index 93a34055..95db1c9b 100644 --- a/datastore/load-test/requirements.txt +++ b/datastore/load-test/requirements.txt @@ -4,6 +4,8 @@ # # pip-compile --no-emit-index-url # +apscheduler==3.10.4 + # via -r requirements.in blinker==1.7.0 # via flask brotli==1.1.0 @@ -84,7 +86,9 @@ psycopg2-binary==2.9.9 python-dateutil==2.8.2 # via pandas pytz==2023.3.post1 - # via pandas + # via + # apscheduler + # pandas pyzmq==25.1.2 # via locust requests==2.31.0 @@ -95,10 +99,13 @@ shapely==2.0.2 # via -r requirements.in six==1.16.0 # via + # apscheduler # geventhttpclient # python-dateutil tzdata==2023.4 # via pandas +tzlocal==5.2 + # via apscheduler urllib3==2.1.0 # via requests werkzeug==3.0.1 diff --git a/datastore/load-test/schedule_write.py b/datastore/load-test/schedule_write.py new file mode 100644 index 00000000..d56969c8 --- /dev/null +++ b/datastore/load-test/schedule_write.py @@ -0,0 +1,102 @@ +import math +import os +import random +import time +import uuid +from collections import namedtuple +from datetime import datetime +from datetime import UTC + +import datastore_pb2 as dstore +import datastore_pb2_grpc as dstore_grpc +import grpc +from apscheduler.executors.pool import ThreadPoolExecutor +from apscheduler.schedulers.blocking import BlockingScheduler +from apscheduler.triggers.cron import CronTrigger +from google.protobuf.timestamp_pb2 import Timestamp +from variables import variable_info + +# from apscheduler.executors.pool import ProcessPoolExecutor + + +# One channel & client per process (not per thread!) +# TODO: Does this affect load on the database? Seems load here is lower then in Rosina's code +channel = grpc.insecure_channel(f"{os.getenv('DSHOST', 'localhost')}:{os.getenv('DSPORT', '50050')}") +client = dstore_grpc.DatastoreStub(channel=channel) + + +crons = [ + (1, "*"), # every minutes + (5, "*/5"), # every 5 minutes, on the five + (5, "1-59/5"), # every 5 minutes, on minute after the five + (5, "2-59/5"), + (5, "3-59/5"), + (5, "4-59/5"), + (10, "*/10"), # every 10 minutes, on the 10 + (10, "1-59/10"), # every 10 minutes, on minute after the five + (10, "2-59/10"), + (10, "3-59/10"), + (10, "4-59/10"), + (10, "5-59/10"), + (10, "6-59/10"), + (10, "7-59/10"), + (10, "8-59/10"), + (10, "9-59/10"), +] + +vars_per_station = 40 # Should be <=44 + +Station = namedtuple("Station", "id lat lon period") + + +def write_data(station): + pub_time = datetime.now(UTC) + # Round observation time to nearest 1, 5, 10 minutes + obs_time = pub_time.replace(minute=int(pub_time.minute / station.period) * station.period, second=0, microsecond=0) + pub_ts = Timestamp() + obs_ts = Timestamp() + pub_ts.FromDatetime(pub_time) + obs_ts.FromDatetime(obs_time) + observations = [] + for var in range(0, vars_per_station): + (param_id, long_name, standard_name, unit) = variable_info[var] + ts_mdata = dstore.TSMetadata( + platform=station.id, + instrument=param_id, + title=long_name, + standard_name=standard_name, + unit=unit, + ) + obs_mdata = dstore.ObsMetadata( + id=str(uuid.uuid4()), + pubtime=pub_ts, + geo_point=dstore.Point(lat=station.lat, lon=station.lon), # One random per station + obstime_instant=obs_ts, + value=str(math.sin(time.mktime(obs_time.timetuple()) / 36000.0) + 2 * var), # TODO: Make dependent station + ) + observations.append(dstore.Metadata1(ts_mdata=ts_mdata, obs_mdata=obs_mdata)) + + request_messages = dstore.PutObsRequest(observations=observations) + response = client.PutObservations(request_messages) + assert response.status == -1 + + +if __name__ == "__main__": + scheduler = BlockingScheduler() + # scheduler.add_executor(ProcessPoolExecutor()) + scheduler.add_executor(ThreadPoolExecutor()) + print(datetime.now()) + for i in range(0, 5000): + (period, cron) = random.choice(crons) + station_id = f"station{i:04d}" + station = Station(station_id, random.uniform(50.0, 55.0), random.uniform(4.0, 8.0), period) + print(station_id, cron, period) + # TODO: Spread less well over time, for example, all use same second, but add jitter < 60 + trigger = CronTrigger(minute=cron, second=random.randint(0, 59), jitter=1) + scheduler.add_job(write_data, args=(station,), id=station_id, trigger=trigger) + print("Press Ctrl+{0} to exit".format("Break" if os.name == "nt" else "C")) + + try: + scheduler.start() + except (KeyboardInterrupt, SystemExit): + pass diff --git a/datastore/load-test/variables.py b/datastore/load-test/variables.py new file mode 100644 index 00000000..1659968a --- /dev/null +++ b/datastore/load-test/variables.py @@ -0,0 +1,46 @@ +variable_info = [ + ("hc3", "Cloud Base Third Layer", "cloud_base_altitude", "ft"), + ("nc2", "Cloud Amount Second Layer", "cloud_cover", "octa"), + ("zm", "Meteorological Optical Range 10 Min Average", "visibility_in_air", "m"), + ("R1H", "Rainfall in last Hour", "rainfall_amount", "mm"), + ("hc", "Cloud Base", "cloud_base_altitude", "ft"), + ("tgn", "Grass Temperature 10cm 10 Min Minimum", "air_temperature", "degrees Celsius"), + ("Tn12", "Air Temperature Minimum last 12 Hours", "air_temperature", "degrees Celsius"), + ("pr", "Precipitation Duration (PWS), 10 Min Sum", "precipitation_duration", "sec"), + ("pg", "Precipitation Intensity (PWS), 10 Min Average", "lwe_precipitation_rate", "mm/h"), + ("tn", "Ambient Temperature 1.5m 10 Min Minimum", "air_temperature", "degrees Celsius"), + ("rg", "Precipitation Intensity (Rain Gauge), 10 Min Average", "precipitation_rate", "mm/h"), + ("hc1", "Cloud Base First Layer", "cloud_base_altitude", "ft"), + ("nc1", "Cloud Amount First Layer", "cloud_cover", "octa"), + ("ts1", "Number of Lightning Discharges at Station", "Lightning on-site", "Number"), + ("nc3", "Cloud Amount Third Layer", "cloud_cover", "octa"), + ("ts2", "Number of Lightning Discharges near Station", "Lightning nearby", "Number"), + ("qg", "Global Solar Radiation 10 Min Average", "total_downwelling_shortwave_flux_in_air", "W m-2"), + ("ff", "Wind Speed at 10m 10 Min Average", "wind_speed", "m s-1"), + ("ww", "wawa Weather Code", "None", "code"), + ("gff", "Wind Gust at 10m 10 Min Maximum", "wind_speed_of_gust", "m s-1"), + ("dd", "Wind Direction 10 Min Average", "wind_from_direction", "degree"), + ("td", "Dew Point Temperature 1.5m 1 Min Average", "dew_point_temperature", "degrees Celsius"), + ("ww-10", "wawa Weather Code for Previous 10 Min Interval", "None", "code"), + ("Tgn12", "Grass Temperature Minimum last 12 Hours", "air_temperature", "degrees Celsius"), + ("ss", "Sunshine Duration", "duration_of_sunshine", "min"), + ("Tn6", "Air Temperature Minimum last 6 Hours", "air_temperature", "degrees Celsius"), + ("dr", "Precipitation Duration (Rain Gauge), 10 Min Sum", "precipitation_duration", "sec"), + ("rh", "Relative Humidity 1 Min Average", "relative_humidity", "%"), + ("hc2", "Cloud Base Second Layer", "cloud_base_altitude", "ft"), + ("Tgn6", "Grass Temperature Minimum last 6 Hours", "air_temperature", "degrees Celsius"), + ("R12H", "Rainfall in last 12 Hours", "rainfall_amount", "mm"), + ("R24H", "Rainfall in last 24 Hours", "rainfall_amount", "mm"), + ("Tx6", "Air Temperature Maximum last 6 Hours", "air_temperature", "degrees Celsius"), + ("Tx24", "Air Temperature Maximum last 24 Hours", "air_temperature", "degrees Celsius"), + ("Tx12", "Air Temperature Maximum last 12 Hours", "air_temperature", "degrees Celsius"), + ("Tgn14", "Grass Temperature Minimum last 14 Hours", "air_temperature", "degrees Celsius"), + ("D1H", "Rainfall Duration in last Hour", "rainfall_duration", "min"), + ("R6H", "Rainfall in last 6 Hours", "rainfall_amount", "mm"), + ("pwc", "Present Weather", "None", "code"), + ("tx", "Ambient Temperature 1.5m 10 Min Maximum", "air_temperature", "degrees Celsius"), + ("nc", "Total cloud cover", "cloud_cover", "octa"), + ("pp", "Air Pressure at Sea Level 1 Min Average", "air_pressure_at_sea_level", "hPa"), + ("Tn14", "Air Temperature Minimum last 14 Hours", "air_temperature", "degrees Celsius"), + ("ta", "Air Temperature 1 Min Average", "air_temperature", "degrees Celsius"), +] From b6d18801dc8199e08bb7c199340a2ad484e63967 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Wed, 17 Jan 2024 09:35:11 +0100 Subject: [PATCH 15/29] Add name to jobs. --- datastore/load-test/schedule_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datastore/load-test/schedule_write.py b/datastore/load-test/schedule_write.py index d56969c8..88716190 100644 --- a/datastore/load-test/schedule_write.py +++ b/datastore/load-test/schedule_write.py @@ -93,7 +93,7 @@ def write_data(station): print(station_id, cron, period) # TODO: Spread less well over time, for example, all use same second, but add jitter < 60 trigger = CronTrigger(minute=cron, second=random.randint(0, 59), jitter=1) - scheduler.add_job(write_data, args=(station,), id=station_id, trigger=trigger) + scheduler.add_job(write_data, args=(station,), id=station_id, name=station_id, trigger=trigger) print("Press Ctrl+{0} to exit".format("Break" if os.name == "nt" else "C")) try: From 8622b5cf2cafa1dcaae7e311b4f4328ca6f5d184 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Wed, 17 Jan 2024 09:38:41 +0100 Subject: [PATCH 16/29] Remove locustfile.py (something went wrong when renaming it to locustfile_read.py). --- datastore/load-test/locustfile.py | 71 ------------------------------- 1 file changed, 71 deletions(-) delete mode 100644 datastore/load-test/locustfile.py diff --git a/datastore/load-test/locustfile.py b/datastore/load-test/locustfile.py deleted file mode 100644 index e4cc5820..00000000 --- a/datastore/load-test/locustfile.py +++ /dev/null @@ -1,71 +0,0 @@ -# Use the following command to generate the python protobuf stuff in -# the correct place (from the root of the repository) -# python -m grpc_tools.protoc --proto_path=datastore/protobuf datastore.proto --python_out=load-test --grpc_python_out=load-test # noqa: E501 -import random -from datetime import datetime - -import datastore_pb2 as dstore -import datastore_pb2_grpc as dstore_grpc -import grpc_user -from google.protobuf.timestamp_pb2 import Timestamp -from locust import task -from shapely import buffer -from shapely import wkt - -parameters = ["ff", "dd", "rh", "pp", "tn"] -# fmt: off -stations = [ - "06203", "06204", "06205", "06207", "06208", "06211", "06214", "06215", "06235", "06239", - "06242", "06251", "06260", "06269", "06270", "06275", "06279", "06280", "06290", "06310", - "06317", "06319", "06323", "06330", "06340", "06344", "06348", "06350", "06356", "06370", - "06375", "06380", "78871", "78873", -] -# fmt: on -points = [ - "POINT(5.179705 52.0988218)", - "POINT(3.3416666666667 52.36)", - "POINT(2.9452777777778 53.824130555556)", - "POINT(4.7811453228565 52.926865008825)", - "POINT(4.342014 51.447744494043)", -] - - -class StoreGrpcUser(grpc_user.GrpcUser): - host = "localhost:50050" - stub_class = dstore_grpc.DatastoreStub - weight = 1 - - @task - def get_data_for_single_timeserie(self): - from_time = Timestamp() - from_time.FromDatetime(datetime(2022, 12, 31)) - to_time = Timestamp() - to_time.FromDatetime(datetime(2023, 1, 1)) - - request = dstore.GetObsRequest( - interval=dstore.TimeInterval(start=from_time, end=to_time), - platforms=[random.choice(stations)], - instruments=[random.choice(parameters)], - ) - response = self.stub.GetObservations(request) - assert len(response.observations) == 1 - assert len(response.observations[0].obs_mdata) == 144 - - @task - def get_data_single_station_through_bbox(self): - from_time = Timestamp() - from_time.FromDatetime(datetime(2022, 12, 31)) - to_time = Timestamp() - to_time.FromDatetime(datetime(2023, 1, 1)) - - point = wkt.loads(random.choice(points)) - poly = buffer(point, 0.0001, quad_segs=1) # Roughly 10 meters around the point - - request = dstore.GetObsRequest( - interval=dstore.TimeInterval(start=from_time, end=to_time), - instruments=[random.choice(parameters)], - inside=dstore.Polygon(points=[dstore.Point(lat=coord[1], lon=coord[0]) for coord in poly.exterior.coords]), - ) - response = self.stub.GetObservations(request) - assert len(response.observations) == 1 - assert len(response.observations[0].obs_mdata) == 144 From 4230ad3a43bb4717357b7ee155bb4a172442b38b Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Wed, 17 Jan 2024 09:52:57 +0100 Subject: [PATCH 17/29] Set up seperate load tests, one that only reads, and one that reads while data is being written. --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cdc93c7a..c74fa7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,29 +58,43 @@ jobs: - name: Test client runs without errors run: DYNAMICTIME=false LOTIME=1000-01-01T00:00:00Z HITIME=9999-12-31T23:59:59Z docker compose run --rm client - - name: Run load test + - name: Run load test (read only) run: | python --version pip install -r datastore/load-test/requirements.txt python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test - locust -f locustfile_write.py,locustfile_read.py --headless -u 5 -r 1 --run-time 300 --only-summary --csv store + locust -f locustfile_read.py --headless -u 5 -r 10 --run-time 60 --only-summary --csv store_read + + - name: Run load test (write + read) + run: | + pip install -r datastore/load-test/requirements.txt + python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test + cd datastore/load-test + python schedule_write.py > schedule_write.log 2>&1 & + locust -f locustfile_read.py --headless -u 5 -r 10 --run-time 60 --only-summary --csv store_rw + kill %1 + cat schedule_write.log - name: Archive load test artifacts uses: actions/upload-artifact@v3 with: name: performance - path: datastore/load-test/store_*.csv + path: | + datastore/load-test/store_read_*.csv + datastore/load-test/store_rw_*.csv - name: Print results run: | pip install csvkit - echo "## Stats" >> $GITHUB_STEP_SUMMARY - csvlook datastore/load-test/store_stats.csv >> $GITHUB_STEP_SUMMARY - echo "## Stats history" >> $GITHUB_STEP_SUMMARY - csvlook datastore/load-test/store_stats_history.csv >> $GITHUB_STEP_SUMMARY - echo "## Failures" >> $GITHUB_STEP_SUMMARY - csvlook datastore/load-test/store_failures.csv >> $GITHUB_STEP_SUMMARY + echo "## Stats (READ ONLY)" >> $GITHUB_STEP_SUMMARY + csvlook datastore/load-test/store_read_stats.csv >> $GITHUB_STEP_SUMMARY + echo "## Failures (READ ONLY)" >> $GITHUB_STEP_SUMMARY + csvlook datastore/load-test/store_read_failures.csv >> $GITHUB_STEP_SUMMARY + echo "## Stats (WRITE + READ)" >> $GITHUB_STEP_SUMMARY + csvlook datastore/load-test/store_rw_stats.csv >> $GITHUB_STEP_SUMMARY + echo "## Failures (WRITE + READ)" >> $GITHUB_STEP_SUMMARY + csvlook datastore/load-test/store_rw_failures.csv >> $GITHUB_STEP_SUMMARY - name: Cleanup if: always() From 147f7f3a1ab05fe217d9bac234c7cc420dcb0002 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 18 Jan 2024 13:45:45 +0100 Subject: [PATCH 18/29] Less output... --- datastore/load-test/schedule_write.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/datastore/load-test/schedule_write.py b/datastore/load-test/schedule_write.py index 88716190..5bfa147f 100644 --- a/datastore/load-test/schedule_write.py +++ b/datastore/load-test/schedule_write.py @@ -85,16 +85,15 @@ def write_data(station): scheduler = BlockingScheduler() # scheduler.add_executor(ProcessPoolExecutor()) scheduler.add_executor(ThreadPoolExecutor()) - print(datetime.now()) + print(f"Now: {datetime.now()}") for i in range(0, 5000): (period, cron) = random.choice(crons) station_id = f"station{i:04d}" station = Station(station_id, random.uniform(50.0, 55.0), random.uniform(4.0, 8.0), period) - print(station_id, cron, period) + # print(station_id, cron, period) # TODO: Spread less well over time, for example, all use same second, but add jitter < 60 trigger = CronTrigger(minute=cron, second=random.randint(0, 59), jitter=1) scheduler.add_job(write_data, args=(station,), id=station_id, name=station_id, trigger=trigger) - print("Press Ctrl+{0} to exit".format("Break" if os.name == "nt" else "C")) try: scheduler.start() From 93ebaac678a95c99f091094dc58ee3b5a32febc1 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 18 Jan 2024 13:52:51 +0100 Subject: [PATCH 19/29] Flush output? --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74fa7f6..7c576f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,6 +75,7 @@ jobs: locust -f locustfile_read.py --headless -u 5 -r 10 --run-time 60 --only-summary --csv store_rw kill %1 cat schedule_write.log + sleep 1 - name: Archive load test artifacts uses: actions/upload-artifact@v3 From 2fe0626f22cd11a7ec6bd1f84ad52bca9c11d620 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 18 Jan 2024 13:56:50 +0100 Subject: [PATCH 20/29] Flush output 2. --- datastore/load-test/schedule_write.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datastore/load-test/schedule_write.py b/datastore/load-test/schedule_write.py index 5bfa147f..137340c2 100644 --- a/datastore/load-test/schedule_write.py +++ b/datastore/load-test/schedule_write.py @@ -98,4 +98,4 @@ def write_data(station): try: scheduler.start() except (KeyboardInterrupt, SystemExit): - pass + print("Shutting down...") From a4f033af2d1bd454d39e10872bc98bfad907b6bf Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 18 Jan 2024 14:16:34 +0100 Subject: [PATCH 21/29] Add to README. --- .github/workflows/ci.yml | 1 - datastore/load-test/README.md | 26 +++++++++++++++++++++----- datastore/load-test/schedule_write.py | 4 ++-- 3 files changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7c576f7f..c74fa7f6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -75,7 +75,6 @@ jobs: locust -f locustfile_read.py --headless -u 5 -r 10 --run-time 60 --only-summary --csv store_rw kill %1 cat schedule_write.log - sleep 1 - name: Archive load test artifacts uses: actions/upload-artifact@v3 diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md index b903876a..8938d190 100644 --- a/datastore/load-test/README.md +++ b/datastore/load-test/README.md @@ -15,7 +15,23 @@ A example run of the test in the ci is shown in the table below for 60 sec runti ## Write test -### Setup & Data preparation +### Write data using apscheduler +[Advanced Python Scheduler](https://apscheduler.readthedocs.io/en/3.x/) is a package that can be +used to schedule a large amount of jobs. + +We represent each station by an apscheduler job, which is scheduled to send data for all variables of that +station once every 1, 5 or 10 minutes (randomly chosen). The timestamps in the data correspond to actual +clock time, and the data values are randomly chosen. The setup will continue processing data until stopped. +This will allow testing of the cleanup functionality of the datastore. + +A setup with 5000 stations roughly represents that actual expected load of the E-SOH system. + +To manually run the data writer, do the following: +```shell +python schedule_write.py +``` + +### Setup & Data preparation (alternative setup using locust for writing) To resemble the load from all EU partner, we need to multiply KNMI data and increase the input time resolution. For this we need to: * Generate dummy data from the KNMI input data by expanding the 10-minute observations to 100-sec observations. @@ -32,12 +48,12 @@ Test requirements * User spawn rate = 1 user per sec ### Run locust via web -```text +```shell locust -f load-test/locustfile_write.py ``` ### Run locust only via command line -```text +```shell locust -f load-test/locustfile_write.py --headless -u -r --run-time --only-summary --csv store_write ``` @@ -59,13 +75,13 @@ test. This is enforced by the weight variable for both user classes. ### Run multiple locust files via web -```text +```shell locust -f load-test/locustfile_write.py,load-test/locustfile_read.py ``` ### Run multiple locust files only via command line -```text +```shell locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless -u -r --run-time --only-summary --csv store_write_read ``` diff --git a/datastore/load-test/schedule_write.py b/datastore/load-test/schedule_write.py index 137340c2..3af6ed06 100644 --- a/datastore/load-test/schedule_write.py +++ b/datastore/load-test/schedule_write.py @@ -85,7 +85,7 @@ def write_data(station): scheduler = BlockingScheduler() # scheduler.add_executor(ProcessPoolExecutor()) scheduler.add_executor(ThreadPoolExecutor()) - print(f"Now: {datetime.now()}") + print(f"Now: {datetime.now()}", flush=True) for i in range(0, 5000): (period, cron) = random.choice(crons) station_id = f"station{i:04d}" @@ -98,4 +98,4 @@ def write_data(station): try: scheduler.start() except (KeyboardInterrupt, SystemExit): - print("Shutting down...") + print("Shutting down...", flush=True) From 07cf8f3cccc83153283daf7e5258889480134c2a Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 18 Jan 2024 15:09:38 +0100 Subject: [PATCH 22/29] Long, high load test run. --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c74fa7f6..3532cfb1 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,7 @@ jobs: python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test python schedule_write.py > schedule_write.log 2>&1 & - locust -f locustfile_read.py --headless -u 5 -r 10 --run-time 60 --only-summary --csv store_rw + locust -f locustfile_read.py --headless -u 10 -r 10 --run-time 1800 --only-summary --csv store_rw kill %1 cat schedule_write.log From 5a8ce29c56c02b9cf5a79b2350b9eb6680a7b5a8 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Tue, 23 Jan 2024 08:48:03 +0100 Subject: [PATCH 23/29] Long, high load test run. --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3532cfb1..eb562566 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,9 +72,11 @@ jobs: python -m grpc_tools.protoc --proto_path=./protobuf datastore.proto --python_out=datastore/load-test --grpc_python_out=datastore/load-test cd datastore/load-test python schedule_write.py > schedule_write.log 2>&1 & - locust -f locustfile_read.py --headless -u 10 -r 10 --run-time 1800 --only-summary --csv store_rw + locust -f locustfile_read.py --headless -u 5 -r 10 --run-time 60 --only-summary --csv store_rw kill %1 + echo Catting schedule_write output... cat schedule_write.log + echo Done catting - name: Archive load test artifacts uses: actions/upload-artifact@v3 From 3d8ecb0d5a440fad417186760f87539a0db610e6 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Wed, 31 Jan 2024 16:41:47 +0100 Subject: [PATCH 24/29] Drop old writing load test. Clarify TODO. --- datastore/load-test/locustfile_read.py | 1 - datastore/load-test/locustfile_write.py | 114 ---------------- .../load-test/netcdf_file_to_requests.py | 122 ------------------ datastore/load-test/schedule_write.py | 5 +- 4 files changed, 3 insertions(+), 239 deletions(-) delete mode 100644 datastore/load-test/locustfile_write.py delete mode 100644 datastore/load-test/netcdf_file_to_requests.py diff --git a/datastore/load-test/locustfile_read.py b/datastore/load-test/locustfile_read.py index 6c84f64e..81e966f8 100644 --- a/datastore/load-test/locustfile_read.py +++ b/datastore/load-test/locustfile_read.py @@ -34,7 +34,6 @@ class StoreGrpcUser(grpc_user.GrpcUser): host = "localhost:50050" stub_class = dstore_grpc.DatastoreStub - weight = 1 @task def get_data_for_single_timeserie(self): diff --git a/datastore/load-test/locustfile_write.py b/datastore/load-test/locustfile_write.py deleted file mode 100644 index 769a569b..00000000 --- a/datastore/load-test/locustfile_write.py +++ /dev/null @@ -1,114 +0,0 @@ -from pathlib import Path - -import datastore_pb2 as dstore -import datastore_pb2_grpc as dstore_grpc -import grpc_user -import psycopg2 -from locust import between -from locust import events -from locust import task -from netcdf_file_to_requests import generate_dummy_requests_from_netcdf_per_station_per_timestamp - -file_path = Path(Path(__file__).resolve().parents[1] / "data-loader" / "test-data" / "KNMI" / "20230101.nc") - -# stations = [ -# "06201", -# "06203", -# "06204", -# "06205", -# "06207", -# "06208", -# "06211", -# "06214", -# "06215", -# "06225", -# "06229", -# "06235", -# "06239", -# "06240", -# "06242", -# "06248", -# "06249", -# "06251", -# "06252", -# "06257", -# "06258", -# "06260", -# "06267", -# "06269", -# "06270", -# "06273", -# "06275", -# "06277", -# "06278", -# "06279", -# "06280", -# "06283", -# "06286", -# "06290", -# "06310", -# "06317", -# "06319", -# "06320", -# "06321", -# "06323", -# "06330", -# "06340", -# "06343", -# "06344", -# "06348", -# "06350", -# "06356", -# "06370", -# "06375", -# "06377", -# "06380", -# "06391", -# "78871", -# "78873", -# "78990", -# ] - - -class IngestionGrpcUser(grpc_user.GrpcUser): - host = "localhost:50050" - stub_class = dstore_grpc.DatastoreStub - wait_time = between(1.5, 2.5) - user_nr = 0 - dummy_observations_all_stations = generate_dummy_requests_from_netcdf_per_station_per_timestamp(file_path, False) - weight = 7 - - def on_start(self): - print(f"User {IngestionGrpcUser.user_nr}") - self.dummy_observations_per_station = IngestionGrpcUser.dummy_observations_all_stations[ - IngestionGrpcUser.user_nr - ] - print(f"Number of messages to send: {len(self.dummy_observations_per_station)}") - print(f'Number of observations in first: {len(self.dummy_observations_per_station[0]["observations"])}') - print(f'Number of observations in last: {len(self.dummy_observations_per_station[-1]["observations"])}') - IngestionGrpcUser.user_nr += 1 - self.index = 0 - - @task - def ingest_data_per_timestamp_per_station(self): - # 44 observations (parameters) per task - observations = self.dummy_observations_per_station[self.index]["observations"] - request_messages = dstore.PutObsRequest(observations=observations) - response = self.stub.PutObservations(request_messages) - assert response.status == -1 - self.index += 1 - - @events.test_stop.add_listener - def on_test_stop(environment, **kwargs): # noqa: N805 - print("Cleaning up test data...") - conn = psycopg2.connect( - database="data", user="postgres", password="mysecretpassword", host="localhost", port="5433" - ) - cursor = conn.cursor() - # delete all details from observations table for date 20230101 - sql = """ DELETE FROM observation WHERE extract(YEAR from obstime_instant)::int = 2023 """ - cursor.execute(sql) - # Commit your changes in the database - conn.commit() - conn.close() - print("Done cleaning up test data") diff --git a/datastore/load-test/netcdf_file_to_requests.py b/datastore/load-test/netcdf_file_to_requests.py deleted file mode 100644 index 831262ce..00000000 --- a/datastore/load-test/netcdf_file_to_requests.py +++ /dev/null @@ -1,122 +0,0 @@ -import math -import uuid -from datetime import timedelta -from pathlib import Path -from time import perf_counter -from typing import List -from typing import Tuple - -import datastore_pb2 as dstore -import pandas as pd -import xarray as xr -from google.protobuf.timestamp_pb2 import Timestamp - - -knmi_parameter_names = ( - "hc3", - "nc2", - "zm", - "R1H", - "hc", - "tgn", - "Tn12", - "pr", - "pg", - "tn", - "rg", - "hc1", - "nc1", - "ts1", - "nc3", - "ts2", - "qg", - "ff", - "ww", - "gff", - "dd", - "td", - "ww-10", - "Tgn12", - "ss", - "Tn6", - "dr", - "rh", - "hc2", - "Tgn6", - "R12H", - "R24H", - "Tx6", - "Tx24", - "Tx12", - "Tgn14", - "D1H", - "R6H", - "pwc", - "tx", - "nc", - "pp", - "Tn14", - "ta", -) - - -def timerange(start_time, end_time, interval_minutes): - current_time = start_time - while current_time < end_time: - yield current_time - current_time += timedelta(minutes=interval_minutes) - - -def generate_dummy_requests_from_netcdf_per_station_per_timestamp( - file_path: Path | str, per_variable: bool = False -) -> Tuple[List, List]: - print("Starting with creating the time series and observations requests.") - create_requests_start = perf_counter() - obs_per_station = [] - - with xr.open_dataset(file_path, engine="netcdf4", chunks=None) as file: # chunks=None to disable dask - for station_id, latitude, longitude, height in zip( - file["station"].values, - file["lat"].values[0], - file["lon"].values[0], - file["height"].values[0], - ): - station_slice = file.sel(station=station_id) - obs_per_timestamp = [] - for idx, time in enumerate(pd.to_datetime(station_slice["time"].data).to_pydatetime()): - # Generate 100-sec data from each 10-min observation - for i in range(0, 600, 100): # 100-sec data - obs_per_parameter = [] - generated_timestamp = time + timedelta(seconds=i) - ts = Timestamp() - ts.FromDatetime(generated_timestamp) - for param_id in knmi_parameter_names: - param = station_slice[param_id] - obs_value = station_slice[param_id].data[idx] # Use 10-minute data value for each - obs_value = 0 if math.isnan(obs_value) else obs_value # dummy data so obs_value doesn't matter - ts_mdata = dstore.TSMetadata( - platform=station_id, - instrument=param_id, - title=param.long_name, - standard_name=param.standard_name if "standard_name" in param.attrs else None, - unit=param.units if "units" in param.attrs else None, - ) - obs_mdata = dstore.ObsMetadata( - id=str(uuid.uuid4()), - geo_point=dstore.Point(lat=latitude, lon=longitude), - obstime_instant=ts, - value=str(obs_value), - ) - observation = dstore.Metadata1(ts_mdata=ts_mdata, obs_mdata=obs_mdata) - obs_per_parameter.append(observation) - if per_variable: - obs_per_timestamp.append({"time": generated_timestamp, "observations": obs_per_parameter}) - obs_per_parameter = [] - - if not per_variable: - obs_per_timestamp.append({"time": generated_timestamp, "observations": obs_per_parameter}) - obs_per_station.append(obs_per_timestamp) - - print("Finished creating the time series and observation requests " f"{perf_counter() - create_requests_start}.") - print(f"Total number of obs generated per station is {len(obs_per_parameter) * len(obs_per_timestamp)}") - return obs_per_station diff --git a/datastore/load-test/schedule_write.py b/datastore/load-test/schedule_write.py index 3af6ed06..be789575 100644 --- a/datastore/load-test/schedule_write.py +++ b/datastore/load-test/schedule_write.py @@ -19,8 +19,9 @@ # from apscheduler.executors.pool import ProcessPoolExecutor -# One channel & client per process (not per thread!) -# TODO: Does this affect load on the database? Seems load here is lower then in Rosina's code +# TODO: We use one channel & client per process (not per thread!). Check if this limits performance! +# Want to try with one channel per metoffice (so split over 20 channels) +# See also the fourth bullet here: https://grpc.io/docs/guides/performance/ channel = grpc.insecure_channel(f"{os.getenv('DSHOST', 'localhost')}:{os.getenv('DSPORT', '50050')}") client = dstore_grpc.DatastoreStub(channel=channel) From cb61709f08cca1af4afebcf6b03372487b4218ee Mon Sep 17 00:00:00 2001 From: Jeffrey Vervoort Date: Wed, 31 Jan 2024 17:23:50 +0100 Subject: [PATCH 25/29] Cleanup of readme after dropping the locust load test. --- datastore/load-test/README.md | 110 ++++-------------- .../response_times_(ms)_1703258125.png | Bin 35679 -> 0 bytes .../total_requests_per_second_1703258125.png | Bin 30097 -> 0 bytes 3 files changed, 22 insertions(+), 88 deletions(-) delete mode 100644 datastore/load-test/docs_images/response_times_(ms)_1703258125.png delete mode 100644 datastore/load-test/docs_images/total_requests_per_second_1703258125.png diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md index 8938d190..b568f561 100644 --- a/datastore/load-test/README.md +++ b/datastore/load-test/README.md @@ -1,111 +1,45 @@ # Load test datastore -Locust is used for performance testing of the datastore. Tests are done on a Macbook M1 Pro (32gb) with -Docker settings 2 CPUs and 6 GB memory. +Locust is used for performance testing of the datastore. ## Read test -Two tasks are defined: 1) get_data_for_single_timeserie and 2) get_data_single_station_through_bbox. As it is unclear -how many users the datastore expect, the test is done for 5 users over 60 seconds in the ci. -A example run of the test in the ci is shown in the table below for 60 sec runtime. +Two tasks are defined: 1) get_data_for_single_timeserie and 2) get_data_single_station_through_bbox. As it is unclear how many users the datastore expect, the test is done for 5 users over 60 seconds in the ci. -| | | | | | | -|-------|-------|----------------|----------|-----------------------|-----------------------| -| Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | -| 5 | 366.5 | 21.282 | 0 | 9 | 12.9 | - -## Write test - -### Write data using apscheduler -[Advanced Python Scheduler](https://apscheduler.readthedocs.io/en/3.x/) is a package that can be -used to schedule a large amount of jobs. - -We represent each station by an apscheduler job, which is scheduled to send data for all variables of that -station once every 1, 5 or 10 minutes (randomly chosen). The timestamps in the data correspond to actual -clock time, and the data values are randomly chosen. The setup will continue processing data until stopped. -This will allow testing of the cleanup functionality of the datastore. - -A setup with 5000 stations roughly represents that actual expected load of the E-SOH system. - -To manually run the data writer, do the following: +### Locust Commands +#### Run locust via web +Example for a single file ```shell -python schedule_write.py +locust -f load-test/locustfile_read.py ``` -### Setup & Data preparation (alternative setup using locust for writing) -To resemble the load from all EU partner, we need to multiply KNMI data and increase the input time resolution. For this we -need to: -* Generate dummy data from the KNMI input data by expanding the 10-minute observations to 100-sec observations. -* Insert the data on a higher temporal resolution. - -Given that the load test should represent 5-min data for 5000 stations, a rate of 17 requests/sec is needed (12*5000/3600). -A (PutObs) request contains the observations for all parameters for one station and one timestamp. - -Test requirements -* Runtime test = 15min (900s) -* Expected #requests in 15min = 15300 (900*17) -* wait_time between tasks = between 1.5 and 2.5 sec. Resulting in 1 requests per 2 sec per user. -* 35 users should lead to a rate of 17 request/sec, resembling EU coverage. -* User spawn rate = 1 user per sec - -### Run locust via web +Example for multiple files ```shell -locust -f load-test/locustfile_write.py +locust -f load-test/.py,load-test/.py ``` -### Run locust only via command line +#### Run locust only via command line +Example for a single file ```shell locust -f load-test/locustfile_write.py --headless -u -r --run-time --only-summary --csv store_write ``` -### Results -Requests/sec: This is the number of completed requests per second. - -| | | | | | | -|---------------------------------|------|----------------|----------|-----------------------|-----------------------| -| Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | -| 1 | 0.5 | 428 | 0 | 110 | 110 | -| 35 | 15.2 | 13 640 | 0 | 180 | 263 | -| 55 | 17.2 | 15 439 | 0 | 790 | 1104 | -| 1 (no wait_time, runtime 1 min) | 15.3 | 864 | 0 | 64 | 65 | - - -## Read & Write Test -Run the read and write test together to test the load, where the write test will have 7 times more users than the read -test. This is enforced by the weight variable for both user classes. - -### Run multiple locust files via web - -```shell -locust -f load-test/locustfile_write.py,load-test/locustfile_read.py -``` - -### Run multiple locust files only via command line - +Example for multiple locust files ```shell -locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless -u -r --run-time --only-summary --csv store_write_read +locust -f load-test/.py,load-test/.py --headless -u -r --run-time --only-summary --csv store_write_read ``` -| | | | | | | | -|-------|-------|------|----------------|----------|-----------------------|-----------------------| -| Test | Users | r/s | Total requests | Failures | Med request time (ms) | Avg request time (ms) | -| Write | 35 | 12.7 | 11423 | 0 | 660 | 696 | -| Read | 5 | 69.0 | 62091 | 0 | 20 | 70 | -| | | | | | | | -| Write | 53 | 14.5 | 13087 | 0 | 1500 | 1522 | -| Read | 7 | 36.4 | 32769 | 0 | 54 | 185 | +## Write test +### Load Estimation +To roughly represent the expected load of the E-SOH system of all EU partners. The setup is 5-min data for 5000 stations, a rate of 17 requests/sec is expected (12*5000/3600). -#### Chart for users(35,5) test: -Total Requests per Second -![Total Requests per Second (write+read)](docs_images/total_requests_per_second_1703258125.png "Total Requests per Second (write+read)") -Total Response Times (ms) -![Total Response Times (ms)](docs_images/response_times_(ms)_1703258125.png "Total Response Times (ms)") +### Write data using apscheduler +[Advanced Python Scheduler](https://apscheduler.readthedocs.io/en/3.x/) is a package that can be +used to schedule a large amount of jobs. -## Rerun tests -run-time here is 180s instead of 900s. -```text -locust -f load-test/locustfile_write.py --headless -u 1 -r 1 --run-time 180 --only-summary \ -&& locust -f load-test/locustfile_write.py --headless -u 35 -r 1 --run-time 180 --only-summary \ -&& locust -f load-test/locustfile_write.py,load-test/locustfile_read.py --headless -u 40 -r 1 --run-time 180 --only-summary +We represent each station by an apscheduler job, which is scheduled to send data for all variables of that station once every 1, 5 or 10 minutes (randomly chosen). The timestamps in the data correspond to actual clock time, and the data values are randomly chosen. The setup will continue processing data until stopped. This will allow testing of the cleanup functionality of the datastore. +To manually run the data writer, do the following: +```shell +python schedule_write.py ``` diff --git a/datastore/load-test/docs_images/response_times_(ms)_1703258125.png b/datastore/load-test/docs_images/response_times_(ms)_1703258125.png deleted file mode 100644 index 2460d4cddc4707c055456ab3e1ba37706888afaa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 35679 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

~6-nmeq0OB9!#MGo0`W$|`F*lTbDPiX6(Q;HLF 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 C21 Date: Thu, 1 Feb 2024 12:04:18 +0100 Subject: [PATCH 26/29] Updates to README. --- datastore/load-test/README.md | 31 ++++++++++++++----------------- 1 file changed, 14 insertions(+), 17 deletions(-) diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md index b568f561..e80103a7 100644 --- a/datastore/load-test/README.md +++ b/datastore/load-test/README.md @@ -1,43 +1,40 @@ # Load test datastore -Locust is used for performance testing of the datastore. ## Read test -Two tasks are defined: 1) get_data_for_single_timeserie and 2) get_data_single_station_through_bbox. As it is unclear how many users the datastore expect, the test is done for 5 users over 60 seconds in the ci. +Locust is used for read performance testing of the datastore. + +Two tasks are defined: 1) `get_data_for_single_timeserie` and 2) get_data_single_station_through_bbox. +Each user does one task, as soon as the query completes, it does another. +We found that the maximum total throughput is reached with about 5 users. ### Locust Commands #### Run locust via web -Example for a single file ```shell locust -f load-test/locustfile_read.py ``` -Example for multiple files -```shell -locust -f load-test/.py,load-test/.py -``` - #### Run locust only via command line -Example for a single file -```shell -locust -f load-test/locustfile_write.py --headless -u -r --run-time --only-summary --csv store_write -``` - -Example for multiple locust files ```shell -locust -f load-test/.py,load-test/.py --headless -u -r --run-time --only-summary --csv store_write_read +locust -f load-test/locustfile_read.py --headless -u -r --run-time --only-summary --csv store_write ``` ## Write test ### Load Estimation -To roughly represent the expected load of the E-SOH system of all EU partners. The setup is 5-min data for 5000 stations, a rate of 17 requests/sec is expected (12*5000/3600). +The expected load of the E-SOH system is data every 5 minutes for 5000 stations, +which gives a rate of 17 requests/sec (12*5000/3600). ### Write data using apscheduler [Advanced Python Scheduler](https://apscheduler.readthedocs.io/en/3.x/) is a package that can be used to schedule a large amount of jobs. -We represent each station by an apscheduler job, which is scheduled to send data for all variables of that station once every 1, 5 or 10 minutes (randomly chosen). The timestamps in the data correspond to actual clock time, and the data values are randomly chosen. The setup will continue processing data until stopped. This will allow testing of the cleanup functionality of the datastore. +We represent each station by an apscheduler job, +which is scheduled to send data for all variables of that station once every 1, 5 or 10 minutes (randomly chosen). +This roughly represents the expected load of the E-SOH system of all EU partners. +The timestamps in the data correspond to actual clock time, and the data values are randomly chosen. +The setup will continue processing data until stopped. +This will allow testing of the cleanup functionality of the datastore. To manually run the data writer, do the following: ```shell From 2ca219dd28ecffbd069476c42a37d2bde25e344f Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 1 Feb 2024 12:05:19 +0100 Subject: [PATCH 27/29] Small fix README. --- datastore/load-test/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/datastore/load-test/README.md b/datastore/load-test/README.md index e80103a7..4ac09fd7 100644 --- a/datastore/load-test/README.md +++ b/datastore/load-test/README.md @@ -4,7 +4,7 @@ ## Read test Locust is used for read performance testing of the datastore. -Two tasks are defined: 1) `get_data_for_single_timeserie` and 2) get_data_single_station_through_bbox. +Two tasks are defined: 1) `get_data_for_single_timeserie` and 2) `get_data_single_station_through_bbox`. Each user does one task, as soon as the query completes, it does another. We found that the maximum total throughput is reached with about 5 users. From 5dff489c574260f1288bf059de156abb4ed1a7a6 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 1 Feb 2024 12:08:19 +0100 Subject: [PATCH 28/29] Disable broken test-ingest test. --- .github/workflows/ci.yml | 52 +++++++++++++++++++++------------------- 1 file changed, 27 insertions(+), 25 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb562566..9e197f7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -101,28 +101,30 @@ jobs: - name: Cleanup if: always() run: docker compose down --volumes - test-ingest: - runs-on: ubuntu-latest - strategy: - matrix: - python-version: ["3.10"] # Add 3.11 back pybind11 bug is fixed - steps: - - name: Checkout the repo - uses: actions/checkout@v3 - - name: Ubuntu setup - run: sudo apt update && sudo apt install libeccodes-data rapidjson-dev pybind11-dev libssl-dev - - name: Python Setup - uses: actions/setup-python@v4 - with: - python-version: ${{ matrix.python-version }} - architecture: x64 - - name: Checkout Source - uses: actions/checkout@v3 - - name: Install Dependencies - run: | - pip install --upgrade pip - pip install pytest-timeout - pip install pytest-cov - pip install ./ingest - - name: Run Tests - run: python -m pytest -v --timeout=60 ./ingest + +# TODO: These tests don't currently work. Uncomment once this is resolved. +# test-ingest: +# runs-on: ubuntu-latest +# strategy: +# matrix: +# python-version: ["3.10"] # Add 3.11 back pybind11 bug is fixed +# steps: +# - name: Checkout the repo +# uses: actions/checkout@v3 +# - name: Ubuntu setup +# run: sudo apt update && sudo apt install libeccodes-data rapidjson-dev pybind11-dev libssl-dev +# - name: Python Setup +# uses: actions/setup-python@v4 +# with: +# python-version: ${{ matrix.python-version }} +# architecture: x64 +# - name: Checkout Source +# uses: actions/checkout@v3 +# - name: Install Dependencies +# run: | +# pip install --upgrade pip +# pip install pytest-timeout +# pip install pytest-cov +# pip install ./ingest +# - name: Run Tests +# run: python -m pytest -v --timeout=60 ./ingest From 68dacb87448680763be980bf4951aa6f630f35f9 Mon Sep 17 00:00:00 2001 From: Lukas Phaf Date: Thu, 1 Feb 2024 12:11:33 +0100 Subject: [PATCH 29/29] Clean up requirements for load-test. --- datastore/load-test/requirements.in | 4 --- datastore/load-test/requirements.txt | 50 +++++++--------------------- 2 files changed, 12 insertions(+), 42 deletions(-) diff --git a/datastore/load-test/requirements.in b/datastore/load-test/requirements.in index 67d06b0e..5dea4069 100644 --- a/datastore/load-test/requirements.in +++ b/datastore/load-test/requirements.in @@ -6,9 +6,5 @@ grpcio-tools~=1.56 grpc-interceptor~=0.15.3 locust~=2.16 -netCDF4~=1.6 shapely~=2.0 -pandas~=2.1 -psycopg2-binary~=2.9 -xarray~=2023.12 apscheduler~=3.10 diff --git a/datastore/load-test/requirements.txt b/datastore/load-test/requirements.txt index 95db1c9b..81d0c6b7 100644 --- a/datastore/load-test/requirements.txt +++ b/datastore/load-test/requirements.txt @@ -13,25 +13,22 @@ brotli==1.1.0 certifi==2023.11.17 # via # geventhttpclient - # netcdf4 # requests -cftime==1.6.3 - # via netcdf4 charset-normalizer==3.3.2 # via requests click==8.1.7 # via flask configargparse==1.7 # via locust -flask==3.0.0 +flask==3.0.1 # via - # flask-basicauth # flask-cors + # flask-login # locust -flask-basicauth==0.2.0 - # via locust flask-cors==4.0.0 # via locust +flask-login==0.6.3 + # via locust gevent==23.9.1 # via # geventhttpclient @@ -54,41 +51,22 @@ itsdangerous==2.1.2 # via flask jinja2==3.1.3 # via flask -locust==2.20.1 +locust==2.21.0 # via -r requirements.in -markupsafe==2.1.3 +markupsafe==2.1.4 # via # jinja2 # werkzeug msgpack==1.0.7 # via locust -netcdf4==1.6.5 - # via -r requirements.in numpy==1.26.3 - # via - # cftime - # netcdf4 - # pandas - # shapely - # xarray -packaging==23.2 - # via xarray -pandas==2.1.4 - # via - # -r requirements.in - # xarray + # via shapely protobuf==4.25.2 # via grpcio-tools -psutil==5.9.7 +psutil==5.9.8 # via locust -psycopg2-binary==2.9.9 - # via -r requirements.in -python-dateutil==2.8.2 - # via pandas -pytz==2023.3.post1 - # via - # apscheduler - # pandas +pytz==2023.4 + # via apscheduler pyzmq==25.1.2 # via locust requests==2.31.0 @@ -101,19 +79,15 @@ six==1.16.0 # via # apscheduler # geventhttpclient - # python-dateutil -tzdata==2023.4 - # via pandas tzlocal==5.2 # via apscheduler -urllib3==2.1.0 +urllib3==2.2.0 # via requests werkzeug==3.0.1 # via # flask + # flask-login # locust -xarray==2023.12.0 - # via -r requirements.in zope-event==5.0 # via gevent zope-interface==6.1