From e42070eefa228bf933f6e2e5178242be577682eb Mon Sep 17 00:00:00 2001 From: hamilcarBarca17 Date: Wed, 2 Aug 2023 18:11:41 -0600 Subject: [PATCH] Completed DynamoDB + DAX Benchmarker with a nice TUI to boot --- .gitignore | 18 + .golangci.yml | 6 + Cargo.toml | 25 ++ Makefile | 36 ++ README.md | 280 ++++++++++++++- ansible.cfg | 14 + ansible/README.md | 322 ++++++++++++++++++ ansible/deploy_benchmarker.yml | 41 +++ .../inventories/local/host_vars/localhost.yml | 8 + ansible/inventories/local/hosts.yml | 3 + ansible/requirements.yml | 4 + .../files/benchmarker-dashboards.ndjson | 5 + .../tasks/init_elk_stack.yml | 32 ++ .../configure_elastic_stack/tasks/main.yml | 8 + .../tasks/stop_elk_stack.yml | 4 + ansible/roles/deploy_cdk/tasks/main.yml | 119 +++++++ ansible/roles/destroy/tasks/main.yml | 54 +++ .../roles/install_prerequisites/tasks/apt.yml | 22 ++ .../install_prerequisites/tasks/aws_cli.yml | 26 ++ .../roles/install_prerequisites/tasks/go.yml | 15 + .../install_prerequisites/tasks/main.yml | 25 ++ .../install_prerequisites/tasks/node.yml | 34 ++ .../install_prerequisites/tasks/rust.yml | 11 + ansible/run_benchmarkers.yml | 110 ++++++ benchmarker.sh | 264 ++++++++++++++ cdk/.gitignore | 8 + cdk/.npmignore | 6 + cdk/README.md | 88 +++++ cdk/bin/dynamodb-dax-benchmarker.ts | 69 ++++ cdk/jest.config.js | 8 + cdk/lib/bastion-host.ts | 50 +++ cdk/lib/dax-benchmarking-stack.ts | 89 +++++ cdk/lib/dynamodb.ts | 27 ++ cdk/lib/types.ts | 10 + go.mod | 40 +++ pkg/app/main.go | 245 +++++++++++++ pkg/models/models.go | 89 +++++ pkg/simulators/operations.go | 110 ++++++ pkg/simulators/simulations.go | 111 ++++++ pkg/simulators/utils.go | 69 ++++ pkg/utils/utils.go | 11 + rustfmt.toml | 10 + screenshots/advanced-mode.png | Bin 0 -> 64844 bytes screenshots/ansible-playbook-tail.png | Bin 0 -> 76768 bytes screenshots/dynamodb-dax-benchmarker.png | Bin 0 -> 35440 bytes scripts/logger.sh | 41 +++ .../randomly-generate-high-velocity-data.sh | 180 ++++++++++ scripts/ui_utils.sh | 36 ++ src/main.rs | 315 +++++++++++++++++ src/models/mod.rs | 102 ++++++ src/simulators/assertions.rs | 72 ++++ src/simulators/mod.rs | 140 ++++++++ src/simulators/operations.rs | 144 ++++++++ src/simulators/utils.rs | 5 + src/timer_utils.rs | 14 + 55 files changed, 3574 insertions(+), 1 deletion(-) create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 Cargo.toml create mode 100644 Makefile create mode 100644 ansible.cfg create mode 100644 ansible/README.md create mode 100644 ansible/deploy_benchmarker.yml create mode 100644 ansible/inventories/local/host_vars/localhost.yml create mode 100644 ansible/inventories/local/hosts.yml create mode 100644 ansible/requirements.yml create mode 100644 ansible/roles/configure_elastic_stack/files/benchmarker-dashboards.ndjson create mode 100644 ansible/roles/configure_elastic_stack/tasks/init_elk_stack.yml create mode 100644 ansible/roles/configure_elastic_stack/tasks/main.yml create mode 100644 ansible/roles/configure_elastic_stack/tasks/stop_elk_stack.yml create mode 100644 ansible/roles/deploy_cdk/tasks/main.yml create mode 100644 ansible/roles/destroy/tasks/main.yml create mode 100644 ansible/roles/install_prerequisites/tasks/apt.yml create mode 100644 ansible/roles/install_prerequisites/tasks/aws_cli.yml create mode 100644 ansible/roles/install_prerequisites/tasks/go.yml create mode 100644 ansible/roles/install_prerequisites/tasks/main.yml create mode 100644 ansible/roles/install_prerequisites/tasks/node.yml create mode 100644 ansible/roles/install_prerequisites/tasks/rust.yml create mode 100644 ansible/run_benchmarkers.yml create mode 100755 benchmarker.sh create mode 100644 cdk/.gitignore create mode 100644 cdk/.npmignore create mode 100644 cdk/README.md create mode 100644 cdk/bin/dynamodb-dax-benchmarker.ts create mode 100644 cdk/jest.config.js create mode 100644 cdk/lib/bastion-host.ts create mode 100644 cdk/lib/dax-benchmarking-stack.ts create mode 100644 cdk/lib/dynamodb.ts create mode 100644 cdk/lib/types.ts create mode 100644 go.mod create mode 100644 pkg/app/main.go create mode 100644 pkg/models/models.go create mode 100644 pkg/simulators/operations.go create mode 100644 pkg/simulators/simulations.go create mode 100644 pkg/simulators/utils.go create mode 100644 pkg/utils/utils.go create mode 100644 rustfmt.toml create mode 100644 screenshots/advanced-mode.png create mode 100644 screenshots/ansible-playbook-tail.png create mode 100644 screenshots/dynamodb-dax-benchmarker.png create mode 100755 scripts/logger.sh create mode 100755 scripts/randomly-generate-high-velocity-data.sh create mode 100755 scripts/ui_utils.sh create mode 100644 src/main.rs create mode 100644 src/models/mod.rs create mode 100644 src/simulators/assertions.rs create mode 100644 src/simulators/mod.rs create mode 100644 src/simulators/operations.rs create mode 100644 src/simulators/utils.rs create mode 100644 src/timer_utils.rs diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..00058d2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +/target +/.idea/ +Cargo.lock +/.scannerwork/ +*.exe +*.exe~ +*.dll +*.so +*.dylib +*.test +*.out +.idea +out/ +main +dynamodb-benchmarker +dax-benchmarker +*.log +*.json diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..d046e63 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,6 @@ +run: + skip-dirs: + - cdk + - src + - scripts + - target diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..fbcd52c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "dynamodb-benchmarker" +version = "0.1.0" +authors = ["Alex Clarke "] +description = "A CLI tool for simulating heavy usage against DynamoDB and publishing metrics to an Elastic Stack for analysis" +readme = "README.md" +edition = "2021" + +[dependencies] +anyhow = "1.0.71" +aws-config = "0.55.3" +aws-sdk-dynamodb = "0.28.0" +aws-types = "0.55.3" +chrono = { version = "0.4.26", features = ["serde"] } +clap = { version = "4.3.14", features = ["derive"] } +elasticsearch = "8.5.0-alpha.1" +lipsum = "0.9.0" +log = "0.4.19" +log4rs = { version = "1.2.0", features = ["console_appender"] } +rand = "0.8.5" +serde = { version = "1.0.171", features = ["derive"] } +serde_json = { version = "1.0.102", features = ["arbitrary_precision"] } +tokio = { version = "1.29.1", features = ["full"] } +tokio-util = "0.7.8" +uuid = { version = "1.4.0", features = ["v4", "fast-rng"] } diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..988285b --- /dev/null +++ b/Makefile @@ -0,0 +1,36 @@ +#!make + +default: build + +.PHONY: init start-elastic-stack stop-elastic-stack build build-dynamodb-benchmarker build-dax-benchmarker run-dynamodb-benchmarker run-dax-benchmarker clean lint + +init: build + @[[ -d ../docker-elk ]] || git clone https://github.com/deviantony/docker-elk.git .. + @cd ../docker-elk && docker compose up setup + @echo "Default login creds: username=elastic, password=changeme" + +start-elastic-stack: + @cd ../docker-elk && docker compose up -d + +stop-elastic-stack: + @cd ../docker-elk && docker compose down + +build-dynamodb-benchmarker: + @cargo clean && rm -f dynamodb-benchmarker && cargo build --release && mv ./target/release/dynamodb-benchmarker . + +build-dax-benchmarker: + @rm -f main && rm -f dax-benchmarker && go build -o dax-benchmarker pkg/app/main.go + +build: build-dynamodb-benchmarker build-dax-benchmarker + +run-dynamodb-benchmarker: + @cargo run + +run-dax-benchmarker: + @go run pkg/app/main.go + +clean: + @cargo clean && rm -f main && rm -f dynamodb-benchmarker && rm -f dax-benchmarker && rm -rf cdk/cdk.out && rm -rf cdk/node_modules + +lint: + @cargo clippy && golangci-lint run \ No newline at end of file diff --git a/README.md b/README.md index b9b233a..68fbf15 100644 --- a/README.md +++ b/README.md @@ -1 +1,279 @@ -# dynamodb-dax-benchmarker \ No newline at end of file +# DynamoDB + DAX Benchmarker +This project houses the Rust and Go code to benchmark the performance of DynamoDB and DAX by simulating heavy loads. + +![main_menu](./screenshots/dynamodb-dax-benchmarker.png) +![advanced_mode](./screenshots/advanced-mode.png) +![ansible_playbook_tail](./screenshots/ansible-playbook-tail.png) + +## Features +* [x] Simulate reads on existing data +* [x] Simulate writes +* [x] Simulate updates +* [x] Simulate deletes +* [x] Record the following metrics + * [x] The type of operation being simulated + * [x] Total simulation time + * [x] Read times + * [x] Write times + * [x] Confirmation of write times (i.e. how long after a write is the item available when performing a read) + * [x] Update times + * [x] Confirmation of update times (i.e. how long after an update is the item available when performing a read) + * [x] Delete times + * [x] Confirmation of delete times (i.e. how long after a delete is the item no longer available when performing a read) +* [x] Randomized selection of which operation to perform +* [x] Multithreaded performance for publishing to a locally running Elasticsearch cluster +* [x] Highly performant concurrent operations against DynamoDB - 1,000 concurrent operations +* [x] Read-only scenarios for tables that are likely to be hit with mostly reads and very few mutating operations +* [x] Randomly generate schemas for DynamoDB with a specified number of attributes and generate random data to query + +**Disclaimer:** This project exists as a proof-of-concept for how to benchmark and evaluate the performance of DynamoDB + DAX. As such, +this project does not contain any unit tests, integration tests, or E2E tests, thus regressions with future updates are possible and +project stability is _not_ guaranteed in perpetuity. + +## Warning! +When making changes to this repository, take extra care to be sure you don't commit the automatically generated variables in +the [hosts file](./ansible/inventories/local/hosts.yml) and in the [host_vars](./ansible/inventories/local/host_vars/localhost.yml). + +These files have variables that are populated automatically to make your life easier instead of having to specify variables +all the time. You can remove them manually, or you can wipe away everything and have them removed for you: + +* `bastion.hosts.BASTION_HOST_PUBLIC_IP:` +* `vpc_id` +* `dax_endpoint` + +## Getting Started + +The easiest way to use this project is to use the [benchmarker.sh](./benchmarker.sh) script's TUI. This will automate everything for you and make +the use of this project as painless as possible! Just ensure it's executable and run the TUI: + +```shell +chmod +x benchmarker.sh +./benchmarker.sh +``` + +This project is broken into several distinct pieces. For more information on each the specific pieces, refer to their respective README's: + +* [Ansible](./ansible/README.md) -- Go here if you're looking to have more control over what's going on across the entire deployment process, local and AWS +* [CDK](./cdk/README.md) -- Go here if you're looking to discover and tweak the AWS stack and resources + +The vast majority of this project is designed to be automated; however, it is also designed to allow the user to customize it as needed. Naturally, customization +is a more challenging course of action here as you'll need to do some of the automated steps manually. I try to detail those steps below. + +### Prerequisites +* The commands are being run in a Debian-based Linux environment (i.e. Ubuntu, WSL, etc.) +* [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) is installed and configured +* Docker is installed (`sudo apt-get update && sudo apt-get install docker-ce docker-ce-cli`) +* The docker compose plugin is installed (`sudo apt-get update && sudo apt-get install docker-compose-plugin`) +* Rust is installed via rustup (`curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh`) +* `jq` is installed (`sudo apt-get install jq`) +* Go is installed ([instructions here](https://go.dev/doc/install)) + +### Setting up your own local Elastic Stack +Fortunately, setting up the Elastic Stack locally is super easy thanks to [this repository](https://github.com/deviantony/docker-elk). + +This setup is automated for you via the `Makefile` target: `init` + +It will + +* Clone the repository to the folder above the current directory +* Go into the folder +* Start the `setup` docker compose to initialize the Elastic Stack. Don't worry. This function is supposed to exit once it's done initializing your local Elastic Stack + +### Starting and Stopping the Elastic Stack +To start the Elastic Stack, you can either manually `cd` into the [docker-elk](../docker-elk) folder and run `docker compose up -d`, or you can use the `start-elastic-stack` target in the [`Makefile`](./Makefile). +Similarly, you can stop the Elastic Stack by either `cd`into the [docker-elk](../docker-elk) folder and running `docker compose down`, or you can use the `stop-elastic-stack` target in the [`Makefile`](./Makefile). + +### Running the Benchmarkers +To run the benchmarker, make sure you've done all the following steps so that the AWS SDK can pick up your credentials: + +* You're logged into your desired AWS account via the AWS CLI + * Ensure you're properly connected with `aws sts get-caller-identity` +* You've exported all the following AWS environment variables, so they can be picked up by the AWS SDK at runtime to authenticate with AWS: + * `AWS_ACCESS_KEY_ID` + * `AWS_SECRET_ACCESS_KEY` + * `AWS_SESSION_TOKEN` + * `AWS_REGION` (not typically defined by the CLI) + * `AWS_ACCOUNT` (this is a special variable that is not normally defined by the AWS CLI: definition is achieved by running `export AWS_ACCOUNT=$(aws sts get-caller-identity | jq -r .Account)`) + * A nifty little shortcut for exporting all but the `AWS_REGION` and `AWS_ACCOUNT` variables is provided with the following command: ```shell eval $(aws configure export-credentials --format env)``` + +There's a few ways to run the benchmarkers, but the easiest is to build them and run their binaries. + +It's as simple as `make build` and running whichever binary that corresponds to the benchmarking you wish to perform; e.g. +* `./dynamodb-benchmarker` +or +* `./dax-benchmarker` + +For both the `dynamodb-benchmarker` and the `dax-benchmarker`, additional help and usage flags can be found using `--help`. This way you can tweak your benchmarking experience as necessary. + +**dynamodb-benchmarker help**: +``` +atusa@atusa-thinkpad:~/code/dynamodb-benchmarker$ ./dynamodb-benchmarker --help +A CLI tool for simulating heavy usage against DynamoDB and publishing metrics to an Elastic Stack for analysis + +Usage: dynamodb-benchmarker [OPTIONS] + +Options: + -c, --concurrent-simulations + The number of concurrent simulations to run [default: 1000] + -a, --attributes + The number of attributes to use when populating and querying the DynamoDB table; minimum value of 1 [default: 5] + -d, --duration + The length of time (in seconds) to run the benchmark for [default: 1800] + -b, --buffer + The buffer size of the Elasticsearch thread's MPSC channel [default: 500] + -u, --username + Local Elasticsearch cluster username [default: elastic] + -p, --password + Local Elasticsearch cluster password [default: changeme] + -i, --index + The Elasticsearch Index to insert data into [default: dynamodb] + -t, --table-name + The DynamoDB table to perform operations against [default: atusa-high-velocity-table] + -r, --read-only + Whether to run a read-only scenario for benchmarking + -h, --help + Print help + -V, --version + Print version +``` + +**dax-benchmarker help**: +``` +atusa@atusa-thinkpad:~/code/dynamodb-benchmarker$ ./dynamodb-benchmarker --help +A CLI tool for simulating heavy usage against DAX and publishing metrics to an Elastic Stack for analysis + +Usage: + dax-benchmarker [flags] + +Flags: + -a, --attributes int The number of attributes to use when populating and querying the DynamoDB table; minimum value of 1 (default 5) + -b, --buffer int The buffer size of the Elasticsearch goroutine's channel (default 500) + -c, --concurrent-simulations int The number of concurrent simulations to run (default 1000) + -d, --duration int The length of time (in seconds) to run the bechmark for (default 1800) + -e, --endpoint string The DAX endpoint to hit when running simulations (assumes secure endpoint, so do not specify port) + -h, --help help for dax-benchmarker + -i, --index string The Elasticsearch Index to insert data into (default "dax") + -p, --password string Local Elasticsearch cluster password (default "changeme") + -r, --read-only Whether to run a read-only scenario for benchmarking + -t, --table string The DynamoDB table to perform operations against (default "atusa-high-velocity-table") + -u, --username string Local Elasticsearch cluster username (default "elastic") +``` + +#### DAX Benchmarker Gotcha +The nature of DAX os that it does not allow external access from outside the VPC it is deployed in. This is due to the +fact that DAX uses a proprietary protocol that does not support TLS. So, in order to run the DAX benchmarker, we must +push it out and run it from the bastion host that is created in the CDK. This is why the host is created. + +To do this manually, you need to know a few pieces of information: + +* The SSH key used to connect to the host -- if you did not specify a key manually, then the key is `~/.ssh/$USER-dax-pair.pem` +* The bastion host's public IP address +* The DAX endpoint URI + +The bastion host's public IP and the DAX Endpoint can be obtained using the following commands +(You'll need to copy/paste the DAX endpoint once you've SSH'd into the bastion host): + +```shell +dax_endpoint=$(aws cloudformation describe-stacks --stack-name "$USER-dax-benchmark-stack" --query "Stacks[0].Outputs[?OutputKey=='DaxEndpoint'].OutputValue" --output text) +bastion_host_ip=$(aws cloudformation describe-stacks --stack-name "$USER-dax-benchmark-stack" --query "Stacks[0].Outputs[?OutputKey=='InstancePublicIp'].OutputValue" --output text) +``` + +Then, you need to upload the `dax-benchmarker` binary to the bastion host: +```shell +scp -i ~/.ssh/"$USER"-dax-pair.pem dax-benchmarker ec2-user@"$bastion_host_ip":/home/ec2-user/ +``` + +Additionally, you'll need to configure the bastion host to use your current AWS CLI creds; so you'll need to run the following +command locally and paste the output exactly into the SSH session: +```shell +aws configure export-credentials --format env +``` + +Finally, you need to SSH into the bastion host with remote port forwarding to your local Elasticsearch cluster (port 9200): +```shell +ssh -i ~/.ssh/"$USER"-dax-pair.pem -R 9200:localhost:9200 ec2-user@"$bastion_host_ip" +``` + +Once you're SSH'd into the bastion host, you'll need to set the `DAX_ENDPOINT` environment variable using the DAX endpoint spit out +by the previous command: + +```shell +export DAX_ENDPOINT='PASTE_DAX_ENDPOINT_HERE' +``` + +Be sure to paste the output of the `aws configure export-credentials --format env` command as well + +Finally, be sure to also export the `AWS_REGION` environment variable that matches the region you deployed your stack into. + +Once you've done all of this, you're ready to run the `dax-benchmarker` from the bastion host and customize the experience however you need using the +configuration parameters provided (`./dax-benchmarker -h`). + +### Scenarios +By default, for both benchmarkers, they perform CRUD simulations that randomly choose to +* Read an existing item +* Write a new item and record how long it takes to confirm it's there (deletes the item afterward) +* Create a new item and update it, then record how long it takes to confirm the update is reflected in subsequent API calls (deletes the item afterward) + +However, sometimes a more realistic test is to simply run in `read-only` mode; This is supported by both benchmarkers via the `-r, --read-only` flag. + +`read-only` mode, for each concurrent simulation, randomly select a time between 0 and 15 seconds, and then execute a read on an existing item. This simulates more realistic behavior from applications +who are only reading from DAX or DynamoDB and not performing any write, update, or delete operations. + +## Accessing the Elastic Stack and analyzing data +By default, the Elastic Stack services are at the following URLs when running locally: + +* Elasticsearch -> `http://localhost:9200` +* Kibana -> `http://localhost:5601` + +The default credentials for accessing them are + +* Username -> `elastic` +* Password -> `changeme` + +Once you're in, you can use Kibana to analyze the data published by the benchmarker. + +This data lives in the `dynamodb` and the `dax` indices of Elasticsearch by default, unless specified otherwise on the clients by the user. + +**Note:** Sometimes the simulations would reach the provisioned throughput thresholds and would be rate-limited by AWS. I set the DynamoDB table to On-Demand +to scale out automatically, however this does not always prevent being rate limited. So that is why I also track the Failed Simulations in the Kibana graphs. + +## Populating the DynamoDB benchmarking table with random data +By default, the clients and CDK create a DynamoDB table titled `$USER-high-velocity-table`. To run the clients with a different table name, use the `-t, --table` arguments. + +If you wish to populate the table with some data, the easiest way to achieve this is via the [randomly-generate-high-velocity-data](./scripts/randomly-generate-high-velocity-data.sh) script. +Simply run it and specify the number of items you wish to populate (rounded to a multiple of 25) via `-i 50`! + +To follow the progress of the script, tail the `/tmp/benchmarker.log` file. + +You can specify different arguments to the script to tweak the settings for the script as necessary: + +``` +atusa@atusa-thinkpad:~/code/dynamodb-benchmarker$ ./scripts/randomly-generate-high-velocity-data.sh --help +randomly-generate-high-velocity-data: A script to randomly generate high-velocity data for some DynamoDB table with random attributes and values for benchmarking purposes. + +USAGE: + randomly-generate-high-velocity-data [OPTIONS] [ARGS]... + + -h, --help Show this usage screen + +ARGS: + -a, --attributes The number of attributes to populate each item in the table with + This defaults to 5 + + -i, --items The number of items to populate the table with + Items are populated 25 at a time, so whatever number you provide will be rounded to the nearest multiple of 25 + + -t, --table The name of the DynamoDB table to populate + This defaults to atusa-high-velocity-table + +``` + +These arguments are provided as a convenience to the user if they so wish to populate a table other than the default one created by the CDK. + +## Troubleshooting +In the event you need more information about any of the automation, you can check the various log files created throughout the application: + +* `/tmp/ansible-playbook-output.log` -- Generated whenever ansible-playbooks are run from the TUI +* `/tmp/benchmarker.log` -- Generated whenever you run the `randomly-generate-high-velocity-data.sh` script outside the TUI +* `/tmp/benchmarker-tui.log` -- Generated by events in the TUI +* `/tmp/dynamodb-population.log` -- Generated whenever you run the `randomly-generate-high-velocity-data.sh` script from the TUI \ No newline at end of file diff --git a/ansible.cfg b/ansible.cfg new file mode 100644 index 0000000..c3b647f --- /dev/null +++ b/ansible.cfg @@ -0,0 +1,14 @@ +[defaults] +forks = 50 +gathering = explicit +host_key_checking = False +nocows = 1 +retry_files_enabled = False +roles_path = ./ansible/roles +timeout = 60 +callback_whitelist = profile_tasks + +[callback_profile_tasks] +sort_order = none +output_limit = 1000 + diff --git a/ansible/README.md b/ansible/README.md new file mode 100644 index 0000000..9c4fa25 --- /dev/null +++ b/ansible/README.md @@ -0,0 +1,322 @@ +# Benchmarking Ansible Automation + +This folder houses all the [Ansible](https://www.ansible.com/) roles to automate the configuration of your local +environment and to deploy the necessary DynamoDB and DAX components to AWS. AWS Deployments leverage +[AWS CDK](https://aws.amazon.com/cdk/) to automate the provisioning of AWS resources. For more information, +navigate to the [CDK directory](../cdk/README.md). + +To just see how to run different plays and their corresponding commands without knowing how it all works together, +skip down to the [Plays](#plays) section below. + +Note that if no `ssh_key_name` is provided, the default value is `$USER-dax-pair` + +## Prerequisites +* You must be logged into the AWS CLI prior to running the CDK. Ensure you're logged into your target AWS account by running + `aws sts get-caller-identity`. +* Install pip (Assuming python3 is already installed): `sudo apt-get install python3-pip` +* Install the most recent version of Ansible and jmespath from pip: `pip3 install --user ansible jmespath` +* Export the local bin path: `export PATH=~/.local/bin:$PATH` +* Install curl (`sudo apt-get install curl`) +* Install the required Ansible dependencies using Ansible Galaxy (`ansible-galaxy install -r requirements.yml`) + +## Initializing the Stack +To initialize the stack (including the local Elastic Stack), run the `deploy_benchmarker.yml` playbook with the `init` tag: +```shell +ansible-playbook -i inventories/local \ +--tags init \ +--ask-become-pass \ +deploy_benchmarker.yml +``` + +## Deploying the Stack +To deploy the entire benchmarking stack all at once, local and AWS, use the following command: +```shell +ansible-playbook -i inventories/local \ +-e vpc_id={{ vpc_id_to_deploy_into }} \ +deploy_benchmarker.yml +``` + +The same prerequisites apply to the CDK with the necessary environment or CDK parameters as is defined in the +[CDK Parameters](../cdk/README.md#cdk-arguments) section of the CDK README. Ansible will only resolve the following variables +for you; all other variables must be supplied by the user a runtime: + +* `localIp` +* `awsAccount` + +## Running the benchmarkers +To run the benchmarkers, run the following command: +```shell +ansible-playbook -i inventories/local \ +-e dax_endpoint={{ the_dax_endpoint_uri }} \ +run_benchmarkers.yml +``` + +### Ansible Command Breakdown +Let's analyze how an ansible command is formed: + +```shell +ansible-playbook -i inventories/local \ +-e vpc_id={{ vpc_id_to_deploy_into }} \ +--ask-become-pass \ +deploy_benchmarker.yml +``` + +`ansible-playbook` is the program that runs our playbook, `deploy_benchmarker.yml`. [Playbooks](https://docs.ansible.com/ansible/latest/user_guide/playbooks_intro.html) +are the main "blueprints" of automation tasks that Ansible uses. + +`-i inventories/local` tells Ansible that we want to use the hosts and variables associated +with the `local` environment. So later in the playbook and +[roles](https://docs.ansible.com/ansible/latest/user_guide/playbooks_reuse_roles.html), when we're +using variables and hosts, we're pulling the corresponding values for this environment. More +information about inventories in Ansible can be found +[here](https://docs.ansible.com/ansible/2.3/intro_inventory.html). Inventories would be a good place +to start learning about Ansible if you're confused by what's happening in this module. + +[This](./inventories/local/host_vars/localhost.yml) is where you'd put variables to persist between runs of this application. +By default, they are only provided for you if you follow the steps in the main repository script. + +`-e vpc_id={{ vpc_id_to_deploy_into }}` is setting an extra variable for the playbook to use (fun fact: `-e` is an alias +for `--extra-vars`). This variable is not defined by default in your [local host vars](./inventories/local/host_vars/localhost.yml) because +we don't know what VPC you want to deploy the stack into. If you're running this using the main TUI script in the root +of this repo, then this is handled graphically for you. This will be set on the first run of the CDK deployment, so you do not have to specify +the `vpc_id` between subsequent runs. Otherwise, if you wish to change the VPC ID for any reason (including prior to an initial run), and +you wish to run this Ansible playbook manually, you can add it to your host vars file. + +`--ask-become-pass` is telling Ansible to prompt you for your sudo password, so it can run installs and other configuration tasks on your behalf. + +`deploy_benchmarker.yml` is the name of our playbook that we want Ansible to run. + +## Using Tags to Control What is Deployed +Each part of the `deploy_benchmarker.yml` playbook has +[tags](https://docs.ansible.com/ansible/latest/user_guide/playbooks_tags.html) associated with them. +These tags allow us to tell Ansible which part(s) of the playbook we want to run. In other words, tags +allow us to tell Ansible which parts of the overall Logstash deployment pipeline we want to run. + +They `deploy_benchmarker.yml` playbook (and a couple of roles) has the following tags in it: + +* `init` +* `init_elk` +* `stop_elk` +* `prerequisites` +* `elk` +* `cdk` +* `run` +* `deploy` +* `destroy` +* `destroy_key_pair` +* `upload` +* `dynamodb` +* `dax` +* `crud` +* `read-only` + +To view all these tags and their associated plays from the `ansible` CLI, run + +```shell +ansible-playbook deploy_benchmarker.yml --list-tags +``` + +Using these tags, we can specify that we only want to run specific parts of the Benchmarking Deployment pipeline that's +defined in the `deploy_benchmarker.yml` playbook. + +For example: If we only wanted to start the ELK (Elasticsearch-Logstash-Kibana) stack, we would run this: + +```shell +ansible-playbook -i inventories/local --tags elk deploy_benchmarker.yml +``` + +Likewise, if we wanted to stop the ELK stack, we'd run this: + +```shell +ansible-playbook -i inventories/local --tags stop_elk deploy_benchmarker.yml +``` + +Note the `--tags` argument. This allows us to tell Ansible to only run tasks or roles that have the +`elk` or `stop_elk` tag on them. + +We can also specify multiple arguments for `--tags` if we wish; for example, if we wanted to simply spin up the local +Elastic stack (synonymous with ELK stack), and deploy the CDK, we'd run the following: + +```shell +ansible-playbook -i inventories/local -e vpc_id=vpc-1234567890 --tags 'elk,cdk' deploy_benchmarker.yml +``` + +## Plays +The following plays can be run from these playbooks using the tags with the following commands: + +#### Initialize Your Local Environment and Elastic Stack +A sudo password is required to install applications, so we tell Ansible to prompt us for it at the start: + +```shell +ansible-playbook -i inventories/local --tags init deploy_benchmarker.yml --ask-become-pass +``` + +#### Deploy CDK and Run the Benchmarkers on the Bastion Host +This assumes you already know the VPC ID to deploy into and have already created an SSH key pair and have the key pair +locally in your `~/.ssh` directory with a `.pem` extension. + +If you did not do this manually, it was done for you automatically and the created pair is under `~/.ssh/$USER-dax-pair.pem`. + +You can either specify the `vpc_id` argument directly via `-e` in the command, or you can hard code +it in your [host_vars](./inventories/local/host_vars/localhost.yml). You must also already be logged into the AWS CLI for +your target environment, or specify a `profile_id` either in your `host_vars` or via `-e`, along with an `aws_region`. If you're not +already logged into AWS, your `profile_id` must be configured to be picked up automatically from your `~/.aws/config` or +`~/.aws/credentials` files with no additional login steps in order to deploy to AWS. + +```shell +ansible-playbook -i inventories/local -e vpc_id=vpc-1234567890 --tags deploy deploy_benchmarker.yml +``` + +#### Shut Down Your Local Elastic Stack +```shell +ansible-playbook -i inventories/local --tags stop_elk deploy_benchmarker.yml +``` + +#### Wipe Away everything +Once more, this assumes you either have the DAX +endpoint and the VPC ID hardcoded in your [host vars](./inventories/local/host_vars/localhost.yml), or you provide them via `-e`. + +If you've already run a CDK deploy via Ansible, then you should not need to specify anything. + +**Note:** For safety purposes, this will _not_ wipe away the `ssk_key_name` in your `~/.ssh` directory. If you specified +a pre-existing key to use for this deployment, it will not be touched. If you did not specify a key name, the automatically +generated key `$USER-dax-pair` will be left in your `~/.ssh` directory. If you wish to delete this pair from your local machine +and remove it from AWS, also specify the `destroy_key_pair` tag as well in the below command. + +You can either specify the `vpc_id` argument directly via `-e` in the command, or you can hard code +it in your [host_vars](./inventories/local/host_vars/localhost.yml). You must also already be logged into the AWS CLI for +your target environment, or specify a `profile_id` either in your `host_vars` or via `-e`, along with an `aws_region`. If you're not +already logged into AWS, your `profile_id` must be configured to be picked up automatically from your `~/.aws/config` or +`~/.aws/credentials` files with no additional login steps in order to deploy to AWS. + +**Destroy Everything, But Leave the ssh_key_name Key-Pair Alone:** +```shell +ansible-playbook -i inventories/local -e vpc_id=vpc-1234567890 --tags destroy deploy_benchmarker.yml +``` + +**Destroy Everything, Including the ssh_key_name Key-Pair** +```shell +ansible-playbook -i inventories/local -e vpc_id=vpc-1234567890 --tags 'destroy,destroy_key_pair' deploy_benchmarker.yml +``` + +### Additional Plays You Can Run + +#### Only Install Prerequisites for Local Machine +A sudo password is required to install applications, so we tell Ansible to prompt us for it at the start: + +```shell +ansible-playbook -i inventories/local --tags prerequisites deploy_benchmarker.yml --ask-become-pass +``` + +#### Start Your Local Elastic Stack +```shell +ansible-playbook -i inventories/local --tags elk deploy_benchmarker.yml +``` + +#### Just Deploy the CDK +This assumes you already know the VPC ID to deploy into and have already created an SSH key pair and have the key pair +locally in your `~/.ssh` directory with a `.pem` extension. If you did not do this manually, it was done for you automatically +and the created pair is under `~/.ssh/$USER-dax-pair.pem`. You can either specify the `vpc_id` +argument directly via `-e` in the command, or you can hard code it in your [host_vars](./inventories/local/host_vars/localhost.yml). + +If you've already run a CDK deploy via Ansible, then you should not need to specify anything. + +You must also already be logged into the AWS CLI for your target environment, or specify a `profile_id` either in your +`host_vars` or via `-e`, along with an `aws_region`. If you're not already logged into AWS, your `profile_id` must be +configured to be picked up automatically from your `~/.aws/config` or `~/.aws/credentials` files with no additional +login steps in order to deploy to AWS. + +```shell +ansible-playbook -i inventories/local --tags cdk deploy_benchmarker.yml +``` + +#### Only Upload the Benchmarkers to the Bastion Host +```shell +ansible-playbook -i inventories/local --tags upload deploy_benchmarker.yml +``` + +#### Run All Benchmarkers and Scenarios +This assumes the CDK is already deployed and an EC2 instance already exists. This also assumes you either have the DAX +endpoint and the VPC ID hardcoded in your [host vars](./inventories/local/host_vars/localhost.yml), or you provide them via `-e`. +If you've already run a CDK deploy via Ansible, then you should not need to specify anything. + +Additionally, You must also already be logged into the AWS CLI for +your target environment, or specify a `profile_id` either in your `host_vars` or via `-e`, along with an `aws_region`. If you're not +already logged into AWS, your `profile_id` must be configured to be picked up automatically from your `~/.aws/config` or +`~/.aws/credentials` files with no additional login steps in order to deploy to AWS: + +```shell +ansible-playbook -i inventories/local --tags run run_benchmarkers.yml +``` + +#### Only Run the DynamoDB/DAX Benchmarker +This assumes the CDK is already deployed and an EC2 instance already exists. This also assumes you either have the DAX +endpoint and the VPC ID hardcoded in your [host vars](./inventories/local/host_vars/localhost.yml), or you provide them via `-e`. +If you've already run a CDK deploy via Ansible, then you should not need to specify anything. + +Additionally, You must also already be logged into the AWS CLI for +your target environment, or specify a `profile_id` either in your `host_vars` or via `-e`, along with an `aws_region`. If you're not +already logged into AWS, your `profile_id` must be configured to be picked up automatically from your `~/.aws/config` or +`~/.aws/credentials` files with no additional login steps in order to deploy to AWS: + +```shell +ansible-playbook -i inventories/local --tags dynamodb deploy_benchmarker.yml +``` + +or + +```shell +ansible-playbook -i inventories/local --tags dax deploy_benchmarker.yml +``` + +Note the difference in tags: `dynamodb` and `dax` + +#### Only Run the Benchmarkers in CRUD/READONLY mode +This assumes the CDK is already deployed and an EC2 instance already exists. This also assumes you either have the DAX +endpoint and the VPC ID hardcoded in your [host vars](./inventories/local/host_vars/localhost.yml), or you provide them via `-e`. +If you've already run a CDK deploy via Ansible, then you should not need to specify anything. + +Additionally, You must also already be logged into the AWS CLI for +your target environment, or specify a `profile_id` either in your `host_vars` or via `-e`, along with an `aws_region`. If you're not +already logged into AWS, your `profile_id` must be configured to be picked up automatically from your `~/.aws/config` or +`~/.aws/credentials` files with no additional login steps in order to deploy to AWS: + +**CRUD:** +```shell +ansible-playbook -i inventories/local --tags crud deploy_benchmarker.yml +``` + +**read-only:** +```shell +ansible-playbook -i inventories/local --tags read-only deploy_benchmarker.yml +``` + +## Supported Variables +The following variables are supported to be specified via the `-e` argument when running the `deploy_benchmarker.yml` +playbook: + +| Variable Name | Description | Required? | +|-------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|-----------| +| `profile_id` | The name of the AWS CLI profile you wish to deploy with;
Defaults to using the `AWS_PROFILE` environment variable | | +| `vpc_id` | The ID of the VPC in the AWS account you're deploying to where you want the CDK components created
Only required on first run only | * | +| `local_ip` | The public IP of your local machine;
Defaults to the response from `curl -s -L checkip.amazonaws.com` | | +| `ssh_key_name` | The name of the SSH key-pair that will be used when creating the EC2 instance to allow you SSH access to it;
Defaults to `$USER-dax-pair` | | +| `aws_account` | The account ID of the AWS account you're deploying into;
Defaults to the result of `aws sts get-caller-identity \| jq -r .Account` | | +| `base_table_name` | The base name to use when creating the DynamoDB table;
Defaults to `high-velocity-table` | | +| `cdk_action` | The action to perform when deploying the CDK;
Defaults to `deploy` | | +| `duration` | How long to run each simulation for;
Defaults to 1800 seconds | | +| `benchmarker` | Which benchmarker to run (i.e. `dynamodb` or `dax`) | | +| `dax_endpoint` | The DAX URI to use to hit the DAX cluster;
Only required when running the benchmarkers and without an initial CDK deploy) | * | + +## Run Order +When first running from scratch, you'll want to run with the `init` tags first to initialize the Elastic Stack and install the prerequisites, then run again without any tags to actually +deploy everything and run the benchmarkers. If you only want to run the benchmarkers, run the `run_benchmarkers.yml` playbook, or specify the `run` tag. + +## Troubleshooting +You can generally get more information about your problem by adding `-vvv` to the end of your +`ansible-playbook` command. The more `v`'s you add, the more verbose the output and the more information +you will get. For example: + +```shell +ansible-playbook -i inventories/local -e cdk_action=destroy --tags 'elk,cdk' deploy_benchmarker.yml -vvv +``` \ No newline at end of file diff --git a/ansible/deploy_benchmarker.yml b/ansible/deploy_benchmarker.yml new file mode 100644 index 0000000..1c226a0 --- /dev/null +++ b/ansible/deploy_benchmarker.yml @@ -0,0 +1,41 @@ +- name: Deploy the benchmarking components + connection: local + hosts: local + gather_facts: yes + roles: + - { role: install_prerequisites, tags: [ never, prerequisites, init ] } + - { role: configure_elastic_stack, tags: elk } + - { role: deploy_cdk, tags: [ cdk, deploy ] } + - { role: destroy, tags: [ never, destroy ], cdk_action: destroy } + tasks: + - name: Populate the DynamoDB table with random data + shell: + chdir: ../scripts + cmd: ./randomly-generate-high-velocity-data.sh -i 5000 + tags: deploy + + - name: Build the benchmarkers using the Makefile + shell: + chdir: ../ + cmd: make build + tags: deploy + +- name: Upload the benchmarkers to the bastion host + hosts: bastion + gather_facts: yes + vars: + ssh_key_name: "{{ hostvars['localhost']['ssh_key_name'] }}" + ansible_ssh_private_key_file: "~/.ssh/{{ ssh_key_name }}.pem" + ansible_ssh_common_args: '-o StrictHostKeyChecking=no' + remote_user: ec2-user + tags: [ upload, deploy ] + tasks: + - copy: + src: "../{{ item }}" + dest: . + mode: 0777 + loop: + - dynamodb-benchmarker + - dax-benchmarker + +- import_playbook: run_benchmarkers.yml \ No newline at end of file diff --git a/ansible/inventories/local/host_vars/localhost.yml b/ansible/inventories/local/host_vars/localhost.yml new file mode 100644 index 0000000..2f2a309 --- /dev/null +++ b/ansible/inventories/local/host_vars/localhost.yml @@ -0,0 +1,8 @@ +user_name: "{{ lookup('env', 'USER') }}" +ssh_key_name: "{{ lookup('env', 'USER') }}-dax-pair" +profile_id: "{{ lookup('env', 'AWS_PROFILE') }}" +aws_region: "{{ lookup('env', 'AWS_REGION') }}" +stack_name: "{{ user_name }}-dax-benchmark-stack" +vpc_id: +base_table_name: +dax_endpoint: diff --git a/ansible/inventories/local/hosts.yml b/ansible/inventories/local/hosts.yml new file mode 100644 index 0000000..97bfd79 --- /dev/null +++ b/ansible/inventories/local/hosts.yml @@ -0,0 +1,3 @@ +local: + hosts: + localhost: \ No newline at end of file diff --git a/ansible/requirements.yml b/ansible/requirements.yml new file mode 100644 index 0000000..3a179c8 --- /dev/null +++ b/ansible/requirements.yml @@ -0,0 +1,4 @@ +--- +collections: + - name: community.general + - name: amazon.aws diff --git a/ansible/roles/configure_elastic_stack/files/benchmarker-dashboards.ndjson b/ansible/roles/configure_elastic_stack/files/benchmarker-dashboards.ndjson new file mode 100644 index 0000000..63045ea --- /dev/null +++ b/ansible/roles/configure_elastic_stack/files/benchmarker-dashboards.ndjson @@ -0,0 +1,5 @@ +{"attributes":{"fieldAttrs":"{}","fieldFormatMap":"{}","fields":"[]","name":"dynamodb","runtimeFieldMap":"{}","sourceFilters":"[]","timeFieldName":"timestamp","title":"dynamodb*","typeMeta":"{}"},"coreMigrationVersion":"8.8.0","created_at":"2023-07-17T16:24:37.085Z","id":"2c97a7eb-1968-415f-9281-370373850ec7","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2023-07-17T17:40:31.727Z","version":"WzI0NiwzXQ=="} +{"attributes":{"controlGroupInput":{"chainingSystem":"HIERARCHICAL","controlStyle":"oneLine","ignoreParentSettingsJSON":"{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}","panelsJSON":"{\"e63ea259-fd86-4227-b1db-33be15a1d500\":{\"type\":\"optionsListControl\",\"order\":0,\"grow\":true,\"width\":\"small\",\"explicitInput\":{\"id\":\"e63ea259-fd86-4227-b1db-33be15a1d500\",\"fieldName\":\"successful\",\"title\":\"Simulation Success\",\"exclude\":false,\"singleSelect\":true,\"selectedOptions\":[],\"enhancements\":{}}},\"30bb36b4-23ed-400c-a98d-866a0e4a9f5c\":{\"type\":\"optionsListControl\",\"order\":1,\"grow\":true,\"width\":\"small\",\"explicitInput\":{\"id\":\"30bb36b4-23ed-400c-a98d-866a0e4a9f5c\",\"fieldName\":\"operation.keyword\",\"title\":\"Operation\",\"selectedOptions\":[],\"enhancements\":{}}},\"c13132b9-3c01-46a6-8ad5-579b8861cbe7\":{\"type\":\"timeSlider\",\"order\":3,\"grow\":true,\"width\":\"large\",\"explicitInput\":{\"id\":\"c13132b9-3c01-46a6-8ad5-579b8861cbe7\",\"title\":\"Time slider\",\"timesliceStartAsPercentageOfTimeRange\":0,\"timesliceEndAsPercentageOfTimeRange\":0.5,\"enhancements\":{}}},\"bbd18673-d743-46d7-8cb4-d4e25408dc70\":{\"type\":\"optionsListControl\",\"order\":2,\"grow\":true,\"width\":\"small\",\"explicitInput\":{\"id\":\"bbd18673-d743-46d7-8cb4-d4e25408dc70\",\"fieldName\":\"scenario.keyword\",\"title\":\"Scenario\",\"singleSelect\":true,\"selectedOptions\":[],\"existsSelected\":false,\"enhancements\":{}}}}"},"description":"1,000 concurrent tasks run against plain DynamoDB without DAX","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":true,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":7,\"h\":9,\"i\":\"30d02c1c-4524-469a-90f9-4a900880edc3\"},\"panelIndex\":\"30d02c1c-4524-469a-90f9-4a900880edc3\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-04f4f1c7-234d-420a-99c9-1b7cce672086\"}],\"state\":{\"visualization\":{\"layerId\":\"04f4f1c7-234d-420a-99c9-1b7cce672086\",\"accessor\":\"5d1065c7-dd42-471b-a7cc-c71d6146cb41\",\"layerType\":\"data\",\"textAlign\":\"center\",\"size\":\"m\",\"colorMode\":\"None\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"04f4f1c7-234d-420a-99c9-1b7cce672086\":{\"columns\":{\"5d1065c7-dd42-471b-a7cc-c71d6146cb41\":{\"label\":\"Total Simulations Run\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":false,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0}}},\"customLabel\":true,\"filter\":{\"query\":\"\",\"language\":\"kuery\"}}},\"columnOrder\":[\"5d1065c7-dd42-471b-a7cc-c71d6146cb41\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":true}},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":7,\"y\":0,\"w\":7,\"h\":9,\"i\":\"2476847d-06a2-44e0-ba3a-e4e28f4e4f18\"},\"panelIndex\":\"2476847d-06a2-44e0-ba3a-e4e28f4e4f18\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-69edb1db-56fc-4d65-827d-73d0141c58b3\"}],\"state\":{\"visualization\":{\"layerId\":\"69edb1db-56fc-4d65-827d-73d0141c58b3\",\"accessor\":\"2499fd5c-89d9-48dc-a735-a1c3f3cb1605\",\"layerType\":\"data\",\"textAlign\":\"center\",\"colorMode\":\"Background\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":3,\"name\":\"custom\",\"reverse\":false,\"rangeType\":\"number\",\"rangeMin\":0,\"rangeMax\":null,\"progression\":\"fixed\",\"stops\":[{\"color\":\"#cc5642\",\"stop\":1},{\"color\":\"#209280\",\"stop\":7844}],\"colorStops\":[{\"color\":\"#cc5642\",\"stop\":0},{\"color\":\"#209280\",\"stop\":1}],\"continuity\":\"above\",\"maxSteps\":5}}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"69edb1db-56fc-4d65-827d-73d0141c58b3\":{\"columns\":{\"2499fd5c-89d9-48dc-a735-a1c3f3cb1605\":{\"label\":\"Successful Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":false},\"customLabel\":true}},\"columnOrder\":[\"2499fd5c-89d9-48dc-a735-a1c3f3cb1605\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}}},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":14,\"y\":0,\"w\":6,\"h\":9,\"i\":\"691a804b-1e9a-43d2-ac48-392b35adf876\"},\"panelIndex\":\"691a804b-1e9a-43d2-ac48-392b35adf876\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-2d530fa8-3685-4636-9444-6bfee67b5929\"}],\"state\":{\"visualization\":{\"layerId\":\"2d530fa8-3685-4636-9444-6bfee67b5929\",\"accessor\":\"3140b49b-3557-454b-87b5-40e6bc7de2f9\",\"layerType\":\"data\",\"colorMode\":\"Background\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":3,\"name\":\"custom\",\"reverse\":false,\"rangeType\":\"number\",\"rangeMin\":null,\"rangeMax\":null,\"progression\":\"fixed\",\"stops\":[{\"color\":\"#209280\",\"stop\":1},{\"color\":\"#cc5642\",\"stop\":448}],\"colorStops\":[{\"color\":\"#209280\",\"stop\":null},{\"color\":\"#cc5642\",\"stop\":1}],\"continuity\":\"all\",\"maxSteps\":5}},\"textAlign\":\"center\",\"titlePosition\":\"top\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"2d530fa8-3685-4636-9444-6bfee67b5929\":{\"columns\":{\"3140b49b-3557-454b-87b5-40e6bc7de2f9\":{\"label\":\"Failed Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"successful: false\",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":false},\"customLabel\":true}},\"columnOrder\":[\"3140b49b-3557-454b-87b5-40e6bc7de2f9\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}}},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":20,\"y\":0,\"w\":28,\"h\":14,\"i\":\"aae73942-116a-4151-b28a-df2fad7c29a6\"},\"panelIndex\":\"aae73942-116a-4151-b28a-df2fad7c29a6\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-98144812-bc27-4cd1-bc1f-2acc843e33bf\"},{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-efc68b03-c9e4-4cde-9011-106077add387\"}],\"state\":{\"visualization\":{\"title\":\"Empty XY chart\",\"legend\":{\"isVisible\":true,\"position\":\"right\",\"shouldTruncate\":false},\"valueLabels\":\"hide\",\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"98144812-bc27-4cd1-bc1f-2acc843e33bf\",\"accessors\":[\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"668f14b7-bb71-4443-8bc6-2e1f5f4595b2\"},{\"layerId\":\"efc68b03-c9e4-4cde-9011-106077add387\",\"layerType\":\"data\",\"accessors\":[\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\"],\"seriesType\":\"bar_stacked\",\"xAccessor\":\"317e8e63-2fa1-4e8c-9505-f504d7527e1a\"}],\"yTitle\":\"Number of Simulations Run\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"98144812-bc27-4cd1-bc1f-2acc843e33bf\":{\"columns\":{\"668f14b7-bb71-4443-8bc6-2e1f5f4595b2\":{\"label\":\"DynamoDB Operation\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"operation.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false},\"customLabel\":true},\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\":{\"label\":\"Successful Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"668f14b7-bb71-4443-8bc6-2e1f5f4595b2\",\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\"],\"sampling\":1,\"incompleteColumns\":{}},\"efc68b03-c9e4-4cde-9011-106077add387\":{\"linkToLayers\":[],\"columns\":{\"317e8e63-2fa1-4e8c-9505-f504d7527e1a\":{\"label\":\"DynamoDB Operation\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"operation.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false},\"customLabel\":true},\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\":{\"label\":\"Failed Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"successful: false\",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"317e8e63-2fa1-4e8c-9505-f504d7527e1a\",\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Total Simulations\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":9,\"w\":20,\"h\":20,\"i\":\"df559a77-a98b-4876-a391-f806103c944e\"},\"panelIndex\":\"df559a77-a98b-4876-a391-f806103c944e\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-b57983e7-74f2-4dca-8832-662a3b733886\"}],\"state\":{\"visualization\":{\"shape\":\"pie\",\"layers\":[{\"layerId\":\"b57983e7-74f2-4dca-8832-662a3b733886\",\"primaryGroups\":[\"32df8c83-ba98-43b5-be81-f4b794a771c6\"],\"metrics\":[\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\",\"collapseFns\":{}}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"b57983e7-74f2-4dca-8832-662a3b733886\":{\"columns\":{\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\":{\"label\":\"Simulation Type\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true},\"customLabel\":true,\"filter\":{\"query\":\"\",\"language\":\"kuery\"}},\"32df8c83-ba98-43b5-be81-f4b794a771c6\":{\"label\":\"Top 5 values of operation.keyword\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"operation.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}}},\"columnOrder\":[\"32df8c83-ba98-43b5-be81-f4b794a771c6\",\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Simulations\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":20,\"y\":14,\"w\":28,\"h\":15,\"i\":\"50bd3d77-7e6b-4208-82c8-ad299d135ab4\"},\"panelIndex\":\"50bd3d77-7e6b-4208-82c8-ad299d135ab4\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-06302f18-484a-4f29-ae26-c56fdceb59e6\"}],\"state\":{\"visualization\":{\"title\":\"Empty XY chart\",\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"06302f18-484a-4f29-ae26-c56fdceb59e6\",\"accessors\":[\"0341eb1d-1095-456f-95e2-aa72cf548479\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"ff4cc1b6-a247-4f67-ab76-5d2982ae80c2\"}],\"xTitle\":\"Timestamp\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"06302f18-484a-4f29-ae26-c56fdceb59e6\":{\"columns\":{\"ff4cc1b6-a247-4f67-ab76-5d2982ae80c2\":{\"label\":\"Timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false},\"customLabel\":true},\"0341eb1d-1095-456f-95e2-aa72cf548479\":{\"label\":\"Simulation Time (ms)\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"simulationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}}},\"customLabel\":true}},\"columnOrder\":[\"ff4cc1b6-a247-4f67-ab76-5d2982ae80c2\",\"0341eb1d-1095-456f-95e2-aa72cf548479\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Average Simulation Time\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":29,\"w\":24,\"h\":24,\"i\":\"96a03fe5-3cbc-40ea-8be3-d5934966732c\"},\"panelIndex\":\"96a03fe5-3cbc-40ea-8be3-d5934966732c\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-046b5ce4-0880-42e2-a66b-60116db6618d\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"shouldTruncate\":false,\"isInside\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"xTitle\":\"Timestamp\",\"yTitle\":\"Operation Time (ms)\",\"yRightTitle\":\" \",\"valuesInLegend\":false,\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"046b5ce4-0880-42e2-a66b-60116db6618d\",\"accessors\":[\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\",\"00c01d69-9d77-4b85-b8ac-b78272b5237a\",\"2c62fa2e-a525-41bc-b6c6-2cf6d2245c0a\",\"b3e714f0-0c64-46b0-ad95-0100119c4df0\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"08eb7e90-ba6e-4316-b59f-31681c6b5556\",\"yConfig\":[{\"forAccessor\":\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\",\"color\":\"#54b399\"},{\"forAccessor\":\"00c01d69-9d77-4b85-b8ac-b78272b5237a\",\"color\":\"#6092c0\"},{\"forAccessor\":\"2c62fa2e-a525-41bc-b6c6-2cf6d2245c0a\",\"color\":\"#d36086\"},{\"forAccessor\":\"b3e714f0-0c64-46b0-ad95-0100119c4df0\",\"color\":\"#9170b8\"}]}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"046b5ce4-0880-42e2-a66b-60116db6618d\":{\"columns\":{\"08eb7e90-ba6e-4316-b59f-31681c6b5556\":{\"label\":\"timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\":{\"label\":\"Read\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"readTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"00c01d69-9d77-4b85-b8ac-b78272b5237a\":{\"label\":\"Write\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"writeTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"2c62fa2e-a525-41bc-b6c6-2cf6d2245c0a\":{\"label\":\"Update\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"updateTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"b3e714f0-0c64-46b0-ad95-0100119c4df0\":{\"label\":\"Delete\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"deleteTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"08eb7e90-ba6e-4316-b59f-31681c6b5556\",\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\",\"00c01d69-9d77-4b85-b8ac-b78272b5237a\",\"2c62fa2e-a525-41bc-b6c6-2cf6d2245c0a\",\"b3e714f0-0c64-46b0-ad95-0100119c4df0\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Average DynamoDB Operation Time\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":37,\"w\":24,\"h\":24,\"i\":\"753d5573-b5c5-4e23-99aa-7bc9532cf075\"},\"panelIndex\":\"753d5573-b5c5-4e23-99aa-7bc9532cf075\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"2c97a7eb-1968-415f-9281-370373850ec7\",\"name\":\"indexpattern-datasource-layer-4db005ab-261f-4969-8ad3-8549c9c772b2\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"None\",\"xTitle\":\"Timestamp\",\"yTitle\":\"Confirmation Time (ms)\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"4db005ab-261f-4969-8ad3-8549c9c772b2\",\"accessors\":[\"ee3788c5-ed90-4b92-903b-721109c6d350\",\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\",\"7f62768f-5f33-4690-918f-72ece4dcb054\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"c663cda5-649a-4fd4-a5e9-3f82f28843ed\",\"yConfig\":[{\"forAccessor\":\"ee3788c5-ed90-4b92-903b-721109c6d350\",\"color\":\"#54b399\"},{\"forAccessor\":\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\",\"color\":\"#6092c0\"},{\"forAccessor\":\"7f62768f-5f33-4690-918f-72ece4dcb054\",\"color\":\"#d36086\"}]}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"4db005ab-261f-4969-8ad3-8549c9c772b2\":{\"columns\":{\"c663cda5-649a-4fd4-a5e9-3f82f28843ed\":{\"label\":\"Timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false},\"customLabel\":true},\"ee3788c5-ed90-4b92-903b-721109c6d350\":{\"label\":\"Write\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"writeItemConfirmationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}}},\"customLabel\":true},\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\":{\"label\":\"Update\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"updateItemConfirmationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}}},\"customLabel\":true},\"7f62768f-5f33-4690-918f-72ece4dcb054\":{\"label\":\"Delete\",\"dataType\":\"number\",\"operationType\":\"average\",\"sourceField\":\"deleteItemConfirmationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"emptyAsNull\":true,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0,\"suffix\":\"ms\"}}},\"customLabel\":true}},\"columnOrder\":[\"c663cda5-649a-4fd4-a5e9-3f82f28843ed\",\"ee3788c5-ed90-4b92-903b-721109c6d350\",\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\",\"7f62768f-5f33-4690-918f-72ece4dcb054\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Average DynamoDB Operation Confirmation Time\"}]","timeRestore":false,"title":"DynamoDB Benchmark","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-07-28T20:02:22.844Z","id":"51721040-24be-11ee-ac2e-ff8a2f0e28da","managed":false,"references":[{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"30d02c1c-4524-469a-90f9-4a900880edc3:indexpattern-datasource-layer-04f4f1c7-234d-420a-99c9-1b7cce672086","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"2476847d-06a2-44e0-ba3a-e4e28f4e4f18:indexpattern-datasource-layer-69edb1db-56fc-4d65-827d-73d0141c58b3","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"691a804b-1e9a-43d2-ac48-392b35adf876:indexpattern-datasource-layer-2d530fa8-3685-4636-9444-6bfee67b5929","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"aae73942-116a-4151-b28a-df2fad7c29a6:indexpattern-datasource-layer-98144812-bc27-4cd1-bc1f-2acc843e33bf","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"aae73942-116a-4151-b28a-df2fad7c29a6:indexpattern-datasource-layer-efc68b03-c9e4-4cde-9011-106077add387","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"df559a77-a98b-4876-a391-f806103c944e:indexpattern-datasource-layer-b57983e7-74f2-4dca-8832-662a3b733886","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"50bd3d77-7e6b-4208-82c8-ad299d135ab4:indexpattern-datasource-layer-06302f18-484a-4f29-ae26-c56fdceb59e6","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"96a03fe5-3cbc-40ea-8be3-d5934966732c:indexpattern-datasource-layer-046b5ce4-0880-42e2-a66b-60116db6618d","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"753d5573-b5c5-4e23-99aa-7bc9532cf075:indexpattern-datasource-layer-4db005ab-261f-4969-8ad3-8549c9c772b2","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"controlGroup_e63ea259-fd86-4227-b1db-33be15a1d500:optionsListDataView","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"controlGroup_30bb36b4-23ed-400c-a98d-866a0e4a9f5c:optionsListDataView","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"controlGroup_bbd18673-d743-46d7-8cb4-d4e25408dc70:optionsListDataView","type":"index-pattern"}],"type":"dashboard","typeMigrationVersion":"8.7.0","updated_at":"2023-07-28T20:02:22.844Z","version":"WzI4MTgsMTJd"} +{"attributes":{"fieldAttrs":"{}","fieldFormatMap":"{}","fields":"[]","name":"dax","runtimeFieldMap":"{}","sourceFilters":"[]","timeFieldName":"timestamp","title":"dax*","typeMeta":"{}"},"coreMigrationVersion":"8.8.0","created_at":"2023-07-20T19:47:27.359Z","id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","managed":false,"references":[],"type":"index-pattern","typeMigrationVersion":"8.0.0","updated_at":"2023-07-20T20:05:34.733Z","version":"WzcyNCw2XQ=="} +{"attributes":{"controlGroupInput":{"chainingSystem":"HIERARCHICAL","controlStyle":"oneLine","ignoreParentSettingsJSON":"{\"ignoreFilters\":false,\"ignoreQuery\":false,\"ignoreTimerange\":false,\"ignoreValidations\":false}","panelsJSON":"{\"e63ea259-fd86-4227-b1db-33be15a1d500\":{\"type\":\"optionsListControl\",\"order\":0,\"grow\":true,\"width\":\"small\",\"explicitInput\":{\"id\":\"e63ea259-fd86-4227-b1db-33be15a1d500\",\"fieldName\":\"successful\",\"title\":\"Simulation Success\",\"exclude\":false,\"singleSelect\":true,\"selectedOptions\":[],\"enhancements\":{}}},\"30bb36b4-23ed-400c-a98d-866a0e4a9f5c\":{\"type\":\"optionsListControl\",\"order\":1,\"grow\":true,\"width\":\"small\",\"explicitInput\":{\"id\":\"30bb36b4-23ed-400c-a98d-866a0e4a9f5c\",\"fieldName\":\"operation.keyword\",\"title\":\"Operation\",\"selectedOptions\":[],\"enhancements\":{}}},\"c13132b9-3c01-46a6-8ad5-579b8861cbe7\":{\"type\":\"timeSlider\",\"order\":3,\"grow\":true,\"width\":\"large\",\"explicitInput\":{\"id\":\"c13132b9-3c01-46a6-8ad5-579b8861cbe7\",\"title\":\"Time slider\",\"timesliceStartAsPercentageOfTimeRange\":0,\"timesliceEndAsPercentageOfTimeRange\":0.48333333333333334,\"enhancements\":{}}},\"aed07897-7b46-4d70-abbc-f4f55cb69076\":{\"type\":\"optionsListControl\",\"order\":2,\"grow\":true,\"width\":\"small\",\"explicitInput\":{\"id\":\"aed07897-7b46-4d70-abbc-f4f55cb69076\",\"fieldName\":\"scenario.keyword\",\"title\":\"Scenario\",\"singleSelect\":true,\"enhancements\":{}}}}"},"description":"1,000 concurrent tasks run against DAX","kibanaSavedObjectMeta":{"searchSourceJSON":"{\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filter\":[]}"},"optionsJSON":"{\"useMargins\":true,\"syncColors\":true,\"syncCursor\":true,\"syncTooltips\":false,\"hidePanelTitles\":false}","panelsJSON":"[{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":0,\"w\":7,\"h\":9,\"i\":\"30d02c1c-4524-469a-90f9-4a900880edc3\"},\"panelIndex\":\"30d02c1c-4524-469a-90f9-4a900880edc3\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-04f4f1c7-234d-420a-99c9-1b7cce672086\"}],\"state\":{\"visualization\":{\"layerId\":\"04f4f1c7-234d-420a-99c9-1b7cce672086\",\"accessor\":\"5d1065c7-dd42-471b-a7cc-c71d6146cb41\",\"layerType\":\"data\",\"textAlign\":\"center\",\"size\":\"m\",\"colorMode\":\"None\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"04f4f1c7-234d-420a-99c9-1b7cce672086\":{\"columns\":{\"5d1065c7-dd42-471b-a7cc-c71d6146cb41\":{\"label\":\"Total Simulations Run\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":false,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":0}}},\"customLabel\":true,\"filter\":{\"query\":\"\",\"language\":\"kuery\"}}},\"columnOrder\":[\"5d1065c7-dd42-471b-a7cc-c71d6146cb41\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"enhancements\":{},\"hidePanelTitles\":true}},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":7,\"y\":0,\"w\":7,\"h\":9,\"i\":\"2476847d-06a2-44e0-ba3a-e4e28f4e4f18\"},\"panelIndex\":\"2476847d-06a2-44e0-ba3a-e4e28f4e4f18\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-69edb1db-56fc-4d65-827d-73d0141c58b3\"}],\"state\":{\"visualization\":{\"layerId\":\"69edb1db-56fc-4d65-827d-73d0141c58b3\",\"accessor\":\"2499fd5c-89d9-48dc-a735-a1c3f3cb1605\",\"layerType\":\"data\",\"textAlign\":\"center\",\"colorMode\":\"Background\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":3,\"name\":\"custom\",\"reverse\":false,\"rangeType\":\"number\",\"rangeMin\":0,\"rangeMax\":null,\"progression\":\"fixed\",\"stops\":[{\"color\":\"#cc5642\",\"stop\":1},{\"color\":\"#209280\",\"stop\":7844}],\"colorStops\":[{\"color\":\"#cc5642\",\"stop\":0},{\"color\":\"#209280\",\"stop\":1}],\"continuity\":\"above\",\"maxSteps\":5}}},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"69edb1db-56fc-4d65-827d-73d0141c58b3\":{\"columns\":{\"2499fd5c-89d9-48dc-a735-a1c3f3cb1605\":{\"label\":\"Successful Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":false},\"customLabel\":true}},\"columnOrder\":[\"2499fd5c-89d9-48dc-a735-a1c3f3cb1605\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}}},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":14,\"y\":0,\"w\":6,\"h\":9,\"i\":\"691a804b-1e9a-43d2-ac48-392b35adf876\"},\"panelIndex\":\"691a804b-1e9a-43d2-ac48-392b35adf876\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsLegacyMetric\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-2d530fa8-3685-4636-9444-6bfee67b5929\"}],\"state\":{\"visualization\":{\"layerId\":\"2d530fa8-3685-4636-9444-6bfee67b5929\",\"accessor\":\"3140b49b-3557-454b-87b5-40e6bc7de2f9\",\"layerType\":\"data\",\"colorMode\":\"Background\",\"palette\":{\"name\":\"custom\",\"type\":\"palette\",\"params\":{\"steps\":3,\"name\":\"custom\",\"reverse\":false,\"rangeType\":\"number\",\"rangeMin\":null,\"rangeMax\":null,\"progression\":\"fixed\",\"stops\":[{\"color\":\"#209280\",\"stop\":1},{\"color\":\"#cc5642\",\"stop\":448}],\"colorStops\":[{\"color\":\"#209280\",\"stop\":null},{\"color\":\"#cc5642\",\"stop\":1}],\"continuity\":\"all\",\"maxSteps\":5}},\"textAlign\":\"center\",\"titlePosition\":\"top\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"2d530fa8-3685-4636-9444-6bfee67b5929\":{\"columns\":{\"3140b49b-3557-454b-87b5-40e6bc7de2f9\":{\"label\":\"Failed Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"successful: false\",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":false},\"customLabel\":true}},\"columnOrder\":[\"3140b49b-3557-454b-87b5-40e6bc7de2f9\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":true,\"enhancements\":{}}},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":20,\"y\":0,\"w\":28,\"h\":14,\"i\":\"aae73942-116a-4151-b28a-df2fad7c29a6\"},\"panelIndex\":\"aae73942-116a-4151-b28a-df2fad7c29a6\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-98144812-bc27-4cd1-bc1f-2acc843e33bf\"},{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-efc68b03-c9e4-4cde-9011-106077add387\"}],\"state\":{\"visualization\":{\"title\":\"Empty XY chart\",\"legend\":{\"isVisible\":true,\"position\":\"right\",\"shouldTruncate\":false},\"valueLabels\":\"hide\",\"preferredSeriesType\":\"bar_stacked\",\"layers\":[{\"layerId\":\"98144812-bc27-4cd1-bc1f-2acc843e33bf\",\"accessors\":[\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\"],\"position\":\"top\",\"seriesType\":\"bar_stacked\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"668f14b7-bb71-4443-8bc6-2e1f5f4595b2\"},{\"layerId\":\"efc68b03-c9e4-4cde-9011-106077add387\",\"layerType\":\"data\",\"accessors\":[\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\"],\"seriesType\":\"bar_stacked\",\"xAccessor\":\"317e8e63-2fa1-4e8c-9505-f504d7527e1a\"}],\"yTitle\":\"Number of Simulations Run\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"xTitle\":\"DAX Operation\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"98144812-bc27-4cd1-bc1f-2acc843e33bf\":{\"columns\":{\"668f14b7-bb71-4443-8bc6-2e1f5f4595b2\":{\"label\":\"DynamoDB Operation\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"operation.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false},\"customLabel\":true},\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\":{\"label\":\"Successful Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"668f14b7-bb71-4443-8bc6-2e1f5f4595b2\",\"eb9ae426-ba37-44e0-8d1d-40f10d771d4d\"],\"sampling\":1,\"incompleteColumns\":{}},\"efc68b03-c9e4-4cde-9011-106077add387\":{\"linkToLayers\":[],\"columns\":{\"317e8e63-2fa1-4e8c-9505-f504d7527e1a\":{\"label\":\"DynamoDB Operation\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"operation.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false},\"customLabel\":true},\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\":{\"label\":\"Failed Simulations\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"filter\":{\"query\":\"successful: false\",\"language\":\"kuery\"},\"params\":{\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"317e8e63-2fa1-4e8c-9505-f504d7527e1a\",\"a0e1b55a-fa0f-4c33-8077-c69c163450ba\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Total Simulations\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":9,\"w\":20,\"h\":20,\"i\":\"df559a77-a98b-4876-a391-f806103c944e\"},\"panelIndex\":\"df559a77-a98b-4876-a391-f806103c944e\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsPie\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-b57983e7-74f2-4dca-8832-662a3b733886\"}],\"state\":{\"visualization\":{\"shape\":\"pie\",\"layers\":[{\"layerId\":\"b57983e7-74f2-4dca-8832-662a3b733886\",\"primaryGroups\":[\"32df8c83-ba98-43b5-be81-f4b794a771c6\"],\"metrics\":[\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\"],\"numberDisplay\":\"percent\",\"categoryDisplay\":\"default\",\"legendDisplay\":\"default\",\"nestedLegend\":false,\"layerType\":\"data\",\"collapseFns\":{}}]},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"b57983e7-74f2-4dca-8832-662a3b733886\":{\"columns\":{\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\":{\"label\":\"Simulation Type\",\"dataType\":\"number\",\"operationType\":\"count\",\"isBucketed\":false,\"scale\":\"ratio\",\"sourceField\":\"___records___\",\"params\":{\"emptyAsNull\":true},\"customLabel\":true,\"filter\":{\"query\":\"\",\"language\":\"kuery\"}},\"32df8c83-ba98-43b5-be81-f4b794a771c6\":{\"label\":\"Top 5 values of operation.keyword\",\"dataType\":\"string\",\"operationType\":\"terms\",\"scale\":\"ordinal\",\"sourceField\":\"operation.keyword\",\"isBucketed\":true,\"params\":{\"size\":5,\"orderBy\":{\"type\":\"column\",\"columnId\":\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\"},\"orderDirection\":\"desc\",\"otherBucket\":true,\"missingBucket\":false,\"parentFormat\":{\"id\":\"terms\"},\"include\":[],\"exclude\":[],\"includeIsRegex\":false,\"excludeIsRegex\":false,\"secondaryFields\":[]}}},\"columnOrder\":[\"32df8c83-ba98-43b5-be81-f4b794a771c6\",\"69185f93-fadf-4e0b-b878-c7bd1f3ebe09\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Simulations\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":20,\"y\":14,\"w\":28,\"h\":15,\"i\":\"50bd3d77-7e6b-4208-82c8-ad299d135ab4\"},\"panelIndex\":\"50bd3d77-7e6b-4208-82c8-ad299d135ab4\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-06302f18-484a-4f29-ae26-c56fdceb59e6\"}],\"state\":{\"visualization\":{\"title\":\"Empty XY chart\",\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"06302f18-484a-4f29-ae26-c56fdceb59e6\",\"accessors\":[\"0341eb1d-1095-456f-95e2-aa72cf548479\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"ff4cc1b6-a247-4f67-ab76-5d2982ae80c2\"}],\"xTitle\":\"Timestamp\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"curveType\":\"CURVE_MONOTONE_X\",\"fittingFunction\":\"Linear\",\"emphasizeFitting\":true,\"endValue\":\"None\"},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"06302f18-484a-4f29-ae26-c56fdceb59e6\":{\"columns\":{\"ff4cc1b6-a247-4f67-ab76-5d2982ae80c2\":{\"label\":\"Timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false},\"customLabel\":true},\"0341eb1d-1095-456f-95e2-aa72cf548479\":{\"label\":\"Simulation Time (ms)\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"77d1e98b-f153-4419-a64a-2be2e82a48c3\"],\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"77d1e98b-f153-4419-a64a-2be2e82a48c3\":{\"label\":\"Simulation Time (ms)\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"simulationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"ff4cc1b6-a247-4f67-ab76-5d2982ae80c2\",\"0341eb1d-1095-456f-95e2-aa72cf548479\",\"77d1e98b-f153-4419-a64a-2be2e82a48c3\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Average Simulation Time\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":0,\"y\":29,\"w\":24,\"h\":24,\"i\":\"96a03fe5-3cbc-40ea-8be3-d5934966732c\"},\"panelIndex\":\"96a03fe5-3cbc-40ea-8be3-d5934966732c\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-046b5ce4-0880-42e2-a66b-60116db6618d\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\",\"shouldTruncate\":false,\"isInside\":false},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"xTitle\":\"Timestamp\",\"yTitle\":\"Operation Time (ms)\",\"yRightTitle\":\" \",\"valuesInLegend\":false,\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"046b5ce4-0880-42e2-a66b-60116db6618d\",\"accessors\":[\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\",\"00c01d69-9d77-4b85-b8ac-b78272b5237a\",\"b3e714f0-0c64-46b0-ad95-0100119c4df0\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"08eb7e90-ba6e-4316-b59f-31681c6b5556\",\"yConfig\":[{\"forAccessor\":\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\",\"color\":\"#54b399\"},{\"forAccessor\":\"00c01d69-9d77-4b85-b8ac-b78272b5237a\",\"color\":\"#6092c0\"},{\"forAccessor\":\"b3e714f0-0c64-46b0-ad95-0100119c4df0\",\"color\":\"#d36086\"}]}],\"curveType\":\"CURVE_MONOTONE_X\",\"emphasizeFitting\":true},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"046b5ce4-0880-42e2-a66b-60116db6618d\":{\"columns\":{\"08eb7e90-ba6e-4316-b59f-31681c6b5556\":{\"label\":\"timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false}},\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\":{\"label\":\"Read\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"2df3bac9-e48d-4873-b47c-1d9559b783b7\"],\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"00c01d69-9d77-4b85-b8ac-b78272b5237a\":{\"label\":\"Write\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"99c5d634-0983-4892-91c4-8dbb396f63d7\"],\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"b3e714f0-0c64-46b0-ad95-0100119c4df0\":{\"label\":\"Delete\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"6b381c1e-575e-4ddd-b4ad-a36d4189c989\"],\"filter\":{\"query\":\"successful: true\",\"language\":\"kuery\"},\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"2df3bac9-e48d-4873-b47c-1d9559b783b7\":{\"label\":\"Read\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"readTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"99c5d634-0983-4892-91c4-8dbb396f63d7\":{\"label\":\"Write\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"writeTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"6b381c1e-575e-4ddd-b4ad-a36d4189c989\":{\"label\":\"Delete\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"deleteTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"08eb7e90-ba6e-4316-b59f-31681c6b5556\",\"9aa10a4a-0e29-42e7-83ba-81f2ab7eab39\",\"00c01d69-9d77-4b85-b8ac-b78272b5237a\",\"b3e714f0-0c64-46b0-ad95-0100119c4df0\",\"2df3bac9-e48d-4873-b47c-1d9559b783b7\",\"99c5d634-0983-4892-91c4-8dbb396f63d7\",\"6b381c1e-575e-4ddd-b4ad-a36d4189c989\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Average DAX Operation Time\"},{\"version\":\"8.8.2\",\"type\":\"lens\",\"gridData\":{\"x\":24,\"y\":37,\"w\":24,\"h\":24,\"i\":\"753d5573-b5c5-4e23-99aa-7bc9532cf075\"},\"panelIndex\":\"753d5573-b5c5-4e23-99aa-7bc9532cf075\",\"embeddableConfig\":{\"attributes\":{\"title\":\"\",\"description\":\"\",\"visualizationType\":\"lnsXY\",\"type\":\"lens\",\"references\":[{\"type\":\"index-pattern\",\"id\":\"e23af679-3d6b-4f98-9231-5dfdf59cb9aa\",\"name\":\"indexpattern-datasource-layer-4db005ab-261f-4969-8ad3-8549c9c772b2\"}],\"state\":{\"visualization\":{\"legend\":{\"isVisible\":true,\"position\":\"right\"},\"valueLabels\":\"hide\",\"fittingFunction\":\"Linear\",\"xTitle\":\"Timestamp\",\"yTitle\":\"Confirmation Time (ms)\",\"axisTitlesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"tickLabelsVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"labelsOrientation\":{\"x\":0,\"yLeft\":0,\"yRight\":0},\"gridlinesVisibilitySettings\":{\"x\":true,\"yLeft\":true,\"yRight\":true},\"preferredSeriesType\":\"line\",\"layers\":[{\"layerId\":\"4db005ab-261f-4969-8ad3-8549c9c772b2\",\"accessors\":[\"ee3788c5-ed90-4b92-903b-721109c6d350\",\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\",\"7f62768f-5f33-4690-918f-72ece4dcb054\"],\"position\":\"top\",\"seriesType\":\"line\",\"showGridlines\":false,\"layerType\":\"data\",\"xAccessor\":\"c663cda5-649a-4fd4-a5e9-3f82f28843ed\",\"yConfig\":[{\"forAccessor\":\"ee3788c5-ed90-4b92-903b-721109c6d350\",\"color\":\"#54b399\"},{\"forAccessor\":\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\",\"color\":\"#6092c0\"},{\"forAccessor\":\"7f62768f-5f33-4690-918f-72ece4dcb054\",\"color\":\"#d36086\"}]}],\"curveType\":\"CURVE_MONOTONE_X\",\"emphasizeFitting\":true},\"query\":{\"query\":\"\",\"language\":\"kuery\"},\"filters\":[],\"datasourceStates\":{\"formBased\":{\"layers\":{\"4db005ab-261f-4969-8ad3-8549c9c772b2\":{\"columns\":{\"c663cda5-649a-4fd4-a5e9-3f82f28843ed\":{\"label\":\"Timestamp\",\"dataType\":\"date\",\"operationType\":\"date_histogram\",\"sourceField\":\"timestamp\",\"isBucketed\":true,\"scale\":\"interval\",\"params\":{\"interval\":\"auto\",\"includeEmptyRows\":true,\"dropPartials\":false},\"customLabel\":true},\"ee3788c5-ed90-4b92-903b-721109c6d350\":{\"label\":\"Write\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"021612c5-1beb-4e62-8f51-67268a79f84b\"],\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\":{\"label\":\"Update\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"df7460b5-aec4-45b8-95e8-a53204de9c1d\"],\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"7f62768f-5f33-4690-918f-72ece4dcb054\":{\"label\":\"Delete\",\"dataType\":\"number\",\"operationType\":\"moving_average\",\"isBucketed\":false,\"scale\":\"ratio\",\"references\":[\"5d42f26b-41ee-4e66-adf6-b1d192f5ddf7\"],\"params\":{\"window\":5,\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}}},\"customLabel\":true},\"021612c5-1beb-4e62-8f51-67268a79f84b\":{\"label\":\"Write\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"writeItemConfirmationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"df7460b5-aec4-45b8-95e8-a53204de9c1d\":{\"label\":\"Update\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"updateItemConfirmationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true},\"5d42f26b-41ee-4e66-adf6-b1d192f5ddf7\":{\"label\":\"Delete\",\"dataType\":\"number\",\"operationType\":\"min\",\"sourceField\":\"deleteItemConfirmationTime\",\"isBucketed\":false,\"scale\":\"ratio\",\"params\":{\"format\":{\"id\":\"number\",\"params\":{\"decimals\":3,\"suffix\":\"ms\"}},\"emptyAsNull\":true},\"customLabel\":true}},\"columnOrder\":[\"c663cda5-649a-4fd4-a5e9-3f82f28843ed\",\"ee3788c5-ed90-4b92-903b-721109c6d350\",\"e9c4b5a8-07c6-4ba3-ae34-dd9ae777dc42\",\"7f62768f-5f33-4690-918f-72ece4dcb054\",\"021612c5-1beb-4e62-8f51-67268a79f84b\",\"df7460b5-aec4-45b8-95e8-a53204de9c1d\",\"5d42f26b-41ee-4e66-adf6-b1d192f5ddf7\"],\"sampling\":1,\"incompleteColumns\":{}}}},\"textBased\":{\"layers\":{}}},\"internalReferences\":[],\"adHocDataViews\":{}}},\"hidePanelTitles\":false,\"enhancements\":{}},\"title\":\"Average DAX Operation Confirmation Time\"}]","timeRestore":false,"title":"DAX Benchmark","version":1},"coreMigrationVersion":"8.8.0","created_at":"2023-07-25T19:29:38.699Z","id":"0fe18820-2736-11ee-a70f-4976799912d8","managed":false,"references":[{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"30d02c1c-4524-469a-90f9-4a900880edc3:indexpattern-datasource-layer-04f4f1c7-234d-420a-99c9-1b7cce672086","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"2476847d-06a2-44e0-ba3a-e4e28f4e4f18:indexpattern-datasource-layer-69edb1db-56fc-4d65-827d-73d0141c58b3","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"691a804b-1e9a-43d2-ac48-392b35adf876:indexpattern-datasource-layer-2d530fa8-3685-4636-9444-6bfee67b5929","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"aae73942-116a-4151-b28a-df2fad7c29a6:indexpattern-datasource-layer-98144812-bc27-4cd1-bc1f-2acc843e33bf","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"aae73942-116a-4151-b28a-df2fad7c29a6:indexpattern-datasource-layer-efc68b03-c9e4-4cde-9011-106077add387","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"df559a77-a98b-4876-a391-f806103c944e:indexpattern-datasource-layer-b57983e7-74f2-4dca-8832-662a3b733886","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"50bd3d77-7e6b-4208-82c8-ad299d135ab4:indexpattern-datasource-layer-06302f18-484a-4f29-ae26-c56fdceb59e6","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"96a03fe5-3cbc-40ea-8be3-d5934966732c:indexpattern-datasource-layer-046b5ce4-0880-42e2-a66b-60116db6618d","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"753d5573-b5c5-4e23-99aa-7bc9532cf075:indexpattern-datasource-layer-4db005ab-261f-4969-8ad3-8549c9c772b2","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"controlGroup_e63ea259-fd86-4227-b1db-33be15a1d500:optionsListDataView","type":"index-pattern"},{"id":"2c97a7eb-1968-415f-9281-370373850ec7","name":"controlGroup_30bb36b4-23ed-400c-a98d-866a0e4a9f5c:optionsListDataView","type":"index-pattern"},{"id":"e23af679-3d6b-4f98-9231-5dfdf59cb9aa","name":"controlGroup_aed07897-7b46-4d70-abbc-f4f55cb69076:optionsListDataView","type":"index-pattern"}],"type":"dashboard","typeMigrationVersion":"8.7.0","updated_at":"2023-07-25T19:29:38.699Z","version":"WzI1MTQsOV0="} +{"excludedObjects":[],"excludedObjectsCount":0,"exportedCount":4,"missingRefCount":0,"missingReferences":[]} \ No newline at end of file diff --git a/ansible/roles/configure_elastic_stack/tasks/init_elk_stack.yml b/ansible/roles/configure_elastic_stack/tasks/init_elk_stack.yml new file mode 100644 index 0000000..1c76acf --- /dev/null +++ b/ansible/roles/configure_elastic_stack/tasks/init_elk_stack.yml @@ -0,0 +1,32 @@ +- name: Clone the docker-elk repo + git: + repo: https://github.com/deviantony/docker-elk.git + dest: ../../docker-elk + ignore_errors: yes + +- name: Build the docker-elk stack just in case a pre-existing version of Elasticsearch needs its nodes upgraded + shell: + chdir: ../../docker-elk + cmd: docker compose build + +- name: Start the docker-elk setup container + shell: + chdir: ../../docker-elk + cmd: docker-compose up setup + +- name: Start the docker-elk stack + shell: + chdir: ../../docker-elk + cmd: docker compose up -d + +- name: Wait 20 seconds for the ELK stack to start + pause: + seconds: 20 + +- name: Import the benchmarking dashboards into Kibana + shell: + cmd: > + curl -X POST http://localhost:5601/api/saved_objects/_import?overwrite=true + -H 'kbn-xsrf: true' + -u 'elastic:changeme' + --form file=@roles/configure_elastic_stack/files/benchmarker-dashboards.ndjson \ No newline at end of file diff --git a/ansible/roles/configure_elastic_stack/tasks/main.yml b/ansible/roles/configure_elastic_stack/tasks/main.yml new file mode 100644 index 0000000..8749e33 --- /dev/null +++ b/ansible/roles/configure_elastic_stack/tasks/main.yml @@ -0,0 +1,8 @@ +- { import_tasks: init_elk_stack.yml, tags: [ never, init, init_elk ] } +- { import_tasks: stop_elk_stack.yml, tags: [ never, stop_elk ] } + +- name: Start the docker-elk stack + shell: + chdir: ../../docker-elk + cmd: docker compose up -d + tags: deploy \ No newline at end of file diff --git a/ansible/roles/configure_elastic_stack/tasks/stop_elk_stack.yml b/ansible/roles/configure_elastic_stack/tasks/stop_elk_stack.yml new file mode 100644 index 0000000..9315422 --- /dev/null +++ b/ansible/roles/configure_elastic_stack/tasks/stop_elk_stack.yml @@ -0,0 +1,4 @@ +- name: Stop the docker-elk stack + shell: + chdir: ../../docker-elk + cmd: docker compose down \ No newline at end of file diff --git a/ansible/roles/deploy_cdk/tasks/main.yml b/ansible/roles/deploy_cdk/tasks/main.yml new file mode 100644 index 0000000..b32a76b --- /dev/null +++ b/ansible/roles/deploy_cdk/tasks/main.yml @@ -0,0 +1,119 @@ +- name: Check if a key-pair following the specified format already exists + stat: + path: "{{ ansible_env.HOME }}/.ssh/{{ ssh_key_name }}.pem" + register: key_pair + changed_when: no + when: "'destroy' not in ansible_run_tags" + +- block: + - name: Create a new key-pair + ec2_key: + name: "{{ ssh_key_name }}" + register: aws_key_pair + + - name: Create the new pem file + file: + path: "{{ ansible_env.HOME }}/.ssh/{{ ssh_key_name }}.pem" + state: touch + mode: '0400' + + - name: Add the generated key-pair to the new file + blockinfile: + path: "{{ ansible_env.HOME }}/.ssh/{{ ssh_key_name }}.pem" + block: "{{ aws_key_pair.key.private_key }}" + + when: + - "'destroy' not in ansible_run_tags" + - not key_pair.stat.exists + +- name: Fetch the current system's public IP + shell: + cmd: curl -s -L checkip.amazonaws.com + register: public_ip_resp + +- name: Fetch the current AWS account ID + shell: + cmd: aws sts get-caller-identity | jq -r .Account + register: aws_account_resp + +- name: Install CDK dependencies + npm: + ci: yes + path: ../cdk + +- name: Bootstrapping the AWS environment + shell: + chdir: ../cdk + cmd: > + npm run build && yes | npm run cdk bootstrap -- + --no-color --require-approval never + --profile {{ profile_id | default("personal") }} + -c vpcId={{ vpc_id }} + -c localIp={{ public_ip_resp.stdout }} + -c sshKeyName={{ ssh_key_name }} + -c awsAccount={{ aws_account_resp.stdout }} + -c baseTableName={{ base_table_name | default('') }} + +- name: Deploying Benchmarking CDK + shell: + chdir: ../cdk + cmd: > + npm run build && yes | npm run cdk {{ cdk_action | default("deploy") }} -- + --no-color --require-approval never + --profile {{ profile_id | default("personal") }} + -c vpcId={{ vpc_id }} + -c localIp={{ public_ip_resp.stdout }} + -c sshKeyName={{ ssh_key_name }} + -c awsAccount={{ aws_account_resp.stdout }} + -c baseTableName={{ base_table_name | default('') }} + register: cdk_response + +- name: Benchmarking CDK deployment summary + debug: + msg: "{{ cdk_response.stderr_lines }}" + +- block: + - name: Fetch the benchmark stack outputs + cloudformation_info: + stack_name: "{{ stack_name }}" + register: benchmark_stack + + - name: Extracting the bastion host IP + set_fact: + bastion_host_ip: "{{ benchmark_stack.cloudformation[stack_name].stack_outputs['InstancePublicIp'] }}" + + - name: Extracting DAX endpoint + set_fact: + dax_endpoint: "{{ benchmark_stack.cloudformation[stack_name].stack_outputs['DaxEndpoint'] }}" + + - name: Setting the dax_endpoint variable in the host vars if it doesn't exist already + lineinfile: + path: inventories/local/host_vars/localhost.yml + line: "dax_endpoint: {{ dax_endpoint }}" + regexp: '^dax_endpoint:' + + - name: Setting the vpc_id variable in the host vars if it doesn't exist already + lineinfile: + path: inventories/local/host_vars/localhost.yml + line: "vpc_id: {{ vpc_id }}" + regexp: '^vpc_id:' + + - block: + - name: Setting the bastion host IP if it doesnt exist in the inventory + lineinfile: + path: inventories/local/hosts.yml + line: | + bastion: + hosts: + {{ bastion_host_ip }}: + regexp: 'bastion:\n\s*hosts:\n\s*(?:\d{1,3}\.){3}\d{1,3}:' + insertafter: EOF + + - name: Add the bastion host to the bastion group + add_host: + name: "{{ bastion_host_ip }}" + groups: bastion + when: + - "'bastion' not in groups" + - "'bastion' not in group_names" + when: "'destroy' not in ansible_run_tags" \ No newline at end of file diff --git a/ansible/roles/destroy/tasks/main.yml b/ansible/roles/destroy/tasks/main.yml new file mode 100644 index 0000000..84e9e46 --- /dev/null +++ b/ansible/roles/destroy/tasks/main.yml @@ -0,0 +1,54 @@ +- name: Wipe away local Elastic Stack + shell: + chdir: ../../docker-elk + cmd: docker compose down -v + ignore_errors: yes + +- name: Wipe away the ELK directory + file: + path: ../../docker-elk + state: absent + ignore_errors: yes + +- name: Run CDK Destroy + import_role: + name: + deploy_cdk + +- name: Delete the key-pair from AWS + ec2_key: + name: "{{ ssh_key_name }}" + state: absent + ignore_errors: yes + tags: [ never, destroy_key_pair ] + +- name: Delete the key pair from your local machine + file: + path: "{{ ansible_env.HOME }}/.ssh/{{ ssh_key_name }}.pem" + state: absent + ignore_errors: yes + tags: [ never, destroy_key_pair ] + +- name: Remove the bastion host from the bastion host group + replace: + path: inventories/local/hosts.yml + replace: '' + regexp: '^bastion:\n\s*hosts:\n\s*(?:\d{1,3}\.){3}\d{1,3}:' + +- name: Reset the dax_endpoint variable in the host vars + lineinfile: + path: inventories/local/host_vars/localhost.yml + line: 'dax_endpoint:' + regexp: '^dax_endpoint:' + +- name: Reset the vpc_id variable in the host vars + lineinfile: + path: inventories/local/host_vars/localhost.yml + line: 'vpc_id:' + regexp: '^vpc_id:' + +- name: Clean the repository using the Makefile + shell: + chdir: ../ + cmd: + make clean \ No newline at end of file diff --git a/ansible/roles/install_prerequisites/tasks/apt.yml b/ansible/roles/install_prerequisites/tasks/apt.yml new file mode 100644 index 0000000..6ad0ba9 --- /dev/null +++ b/ansible/roles/install_prerequisites/tasks/apt.yml @@ -0,0 +1,22 @@ +- name: Add Docker's official GPG key + apt_key: + url: https://download.docker.com/linux/ubuntu/gpg + keyring: /etc/apt/keyrings/docker.gpg + +- name: Set up docker APT repository + apt_repository: + repo: "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable" + +- name: Install the required APT dependencies + apt: + update_cache: yes + name: + - docker-ce + - docker-ce-cli + - docker-compose + - containerd.io + - docker-compose-plugin + - jq + - unzip + - curl + - git \ No newline at end of file diff --git a/ansible/roles/install_prerequisites/tasks/aws_cli.yml b/ansible/roles/install_prerequisites/tasks/aws_cli.yml new file mode 100644 index 0000000..4193d3d --- /dev/null +++ b/ansible/roles/install_prerequisites/tasks/aws_cli.yml @@ -0,0 +1,26 @@ +- name: Check if AWS CLI is installed + shell: + cmd: hash aws 2> /dev/null + ignore_errors: yes + changed_when: no + register: awscli_installation_status + +- block: + - name: Download the AWS CLI from AWS + unarchive: + src: https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip + dest: "{{ ansible_env.HOME }}/Downloads" + group: "{{ user_name }}" + owner: "{{ user_name }}" + remote_src: yes + + - name: Install the AWS CLI + shell: + cmd: "{{ ansible_env.HOME }}/Downloads/aws/install" + + - name: Cleanup downloaded AWS installation files + file: + path: "{{ ansible_env.HOME }}/Downloads/aws/" + state: absent + + when: awscli_installation_status.rc | int != 0 \ No newline at end of file diff --git a/ansible/roles/install_prerequisites/tasks/go.yml b/ansible/roles/install_prerequisites/tasks/go.yml new file mode 100644 index 0000000..ef033ed --- /dev/null +++ b/ansible/roles/install_prerequisites/tasks/go.yml @@ -0,0 +1,15 @@ +- name: Check if Go is installed + shell: + cmd: command -v go 2> /dev/null + ignore_errors: yes + changed_when: no + register: go_installation_status + +- name: Install Go 1.20 + unarchive: + src: https://go.dev/dl/go1.20.5.linux-amd64.tar.gz + dest: /usr/local + creates: /usr/local/go + remote_src: yes + become: yes + when: go_installation_status.rc | int != 0 \ No newline at end of file diff --git a/ansible/roles/install_prerequisites/tasks/main.yml b/ansible/roles/install_prerequisites/tasks/main.yml new file mode 100644 index 0000000..257e71a --- /dev/null +++ b/ansible/roles/install_prerequisites/tasks/main.yml @@ -0,0 +1,25 @@ +- { import_tasks: aws_cli.yml, become: yes } +- import_tasks: rust.yml +- import_tasks: go.yml +- import_tasks: node.yml +- { import_tasks: apt.yml, become: yes } + +- name: Install CDK + npm: + name: "{{ item }}" + global: yes + loop: + - aws-cdk + - typescript + +- name: Check if golangci-lint is installed + shell: + cmd: command -v golangci-lint 2> /dev/null + ignore_errors: yes + changed_when: no + register: golangci_lint_installation_status + +- name: Install golangci-lint + shell: + cmd: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b /usr/local/bin v1.53.3 + when: golangci_lint_installation_status.rc | int != 0 diff --git a/ansible/roles/install_prerequisites/tasks/node.yml b/ansible/roles/install_prerequisites/tasks/node.yml new file mode 100644 index 0000000..47b0b73 --- /dev/null +++ b/ansible/roles/install_prerequisites/tasks/node.yml @@ -0,0 +1,34 @@ +- name: Check if node is installed + shell: + cmd: hash node 2> /dev/null + ignore_errors: yes + changed_when: no + register: node_installation_status + +- block: + - name: Install nvm + shell: > + curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash + args: + creates: "{{ ansible_env.HOME }}/.nvm/nvm.sh" + + - name: Install Node.JS + shell: + cmd: | + export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" + [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + nvm install node + + - name: Add NVM exports to bashrc + lineinfile: + path: "{{ ansible_env.HOME }}/.bashrc" + line: 'export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"' + regexp: '^export NVM_DIR=.+' + + - name: Add NVM script to bashrc + lineinfile: + path: "{{ ansible_env.HOME }}/.bashrc" + line: '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' + regexp: '\[ -s |\$NVM_DIR/nvm\.sh \].+' + + when: node_installation_status.rc | int != 0 \ No newline at end of file diff --git a/ansible/roles/install_prerequisites/tasks/rust.yml b/ansible/roles/install_prerequisites/tasks/rust.yml new file mode 100644 index 0000000..b51f554 --- /dev/null +++ b/ansible/roles/install_prerequisites/tasks/rust.yml @@ -0,0 +1,11 @@ +- name: Check if rustup is installed + shell: + cmd: command -v rustup 2> /dev/null + ignore_errors: yes + changed_when: no + register: rustup_installation_status + +- name: Install Rust via Rustup + shell: > + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + when: rustup_installation_status.rc | int != 0 \ No newline at end of file diff --git a/ansible/run_benchmarkers.yml b/ansible/run_benchmarkers.yml new file mode 100644 index 0000000..406cdb0 --- /dev/null +++ b/ansible/run_benchmarkers.yml @@ -0,0 +1,110 @@ +- name: Get AWS Credentials + connection: local + hosts: local + gather_facts: yes + tags: [ run, deploy ] + tasks: + - name: Ensure the user is logged into their AWS CLI + assert: + that: + - aws_region is defined + - profile_id is defined + - dax_endpoint is defined + + - name: Get the environment variables to set on the bastion host for the current AWS profile + shell: + cmd: aws configure export-credentials + register: aws_creds + + - name: Register the aws_creds as a fact for the benchmarkers playbook to receive + set_fact: + aws_credentials: "{{ aws_creds.stdout }}" + +- name: Run the benchmarkers + hosts: bastion + gather_facts: no + vars: + ssh_key_name: "{{ hostvars['localhost']['ssh_key_name'] }}" + ansible_ssh_private_key_file: "~/.ssh/{{ ssh_key_name }}.pem" + ansible_ssh_common_args: '-o StrictHostKeyChecking=no -R 9200:localhost:9200' + tags: [ run, deploy ] + remote_user: ec2-user + tasks: + - name: Run the DynamoDB benchmarker in CRUD mode + shell: + cmd: > + export AWS_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('AccessKeyId') }}"; + export AWS_SECRET_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SecretAccessKey') }}"; + export AWS_SESSION_TOKEN="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SessionToken') }}"; + export AWS_CREDENTIAL_EXPIRATION="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('Expiration') }}"; + export AWS_REGION="{{ hostvars['localhost']['aws_region'] }}"; + ./dynamodb-benchmarker -d "{{ duration | default(1800) | int }}" -t "{{ hostvars['localhost']['user_name'] }}"-high-velocity-table + executable: /bin/bash + tags: + - dynamodb + - crud + + - name: Run the DynamoDB benchmarker in read-only mode + shell: + cmd: > + export AWS_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('AccessKeyId') }}"; + export AWS_SECRET_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SecretAccessKey') }}"; + export AWS_SESSION_TOKEN="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SessionToken') }}"; + export AWS_CREDENTIAL_EXPIRATION="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('Expiration') }}"; + export AWS_REGION="{{ hostvars['localhost']['aws_region'] }}"; + ./dynamodb-benchmarker -d "{{ duration | default(1800) | int }}" -t "{{ hostvars['localhost']['user_name'] }}"-high-velocity-table -r + executable: /bin/bash + tags: + - dynamodb + - read-only + + - name: Run the DAX benchmarker in CRUD mode + shell: + cmd: > + export AWS_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('AccessKeyId') }}"; + export AWS_SECRET_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SecretAccessKey') }}"; + export AWS_SESSION_TOKEN="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SessionToken') }}"; + export AWS_CREDENTIAL_EXPIRATION="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('Expiration') }}"; + export AWS_REGION="{{ hostvars['localhost']['aws_region'] }}"; + export DAX_ENDPOINT="{{ hostvars['localhost']['dax_endpoint'] }}"; + unset cmd; + basecmd='./dax-benchmarker -c 100 + -d 115 + -t "{{ hostvars['localhost']['user_name'] }}"-high-velocity-table + -e "{{ hostvars['localhost']['dax_endpoint'] }}"'; + for i in $(seq 1 9); do + cmd+="$basecmd & "; + done; + cmd+="$basecmd"; + timeout -s SIGINT "{{ duration | default(1800) | int }}" bash -c "while :; do $cmd; done" + executable: /bin/bash + ignore_errors: yes + tags: + - dax + - crud + + - name: Run the DAX benchmarker in read-only mode + shell: + cmd: > + export AWS_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('AccessKeyId') }}"; + export AWS_SECRET_ACCESS_KEY="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SecretAccessKey') }}"; + export AWS_SESSION_TOKEN="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('SessionToken') }}"; + export AWS_CREDENTIAL_EXPIRATION="{{ hostvars['localhost']['aws_credentials'] | community.general.json_query('Expiration') }}"; + export AWS_REGION="{{ hostvars['localhost']['aws_region'] }}"; + export DAX_ENDPOINT="{{ hostvars['localhost']['dax_endpoint'] }}"; + unset cmd; + basecmd='./dax-benchmarker -c 100 + -d 115 + -r + -t "{{ hostvars['localhost']['user_name'] }}"-high-velocity-table + -e "{{ hostvars['localhost']['dax_endpoint'] }}"'; + for i in $(seq 1 9); do + cmd+="$basecmd & "; + done; + cmd+="$basecmd"; + timeout -s SIGINT "{{ duration | default(1800) | int }}" bash -c "while :; do $cmd; done" + executable: /bin/bash + ignore_errors: yes + tags: + - dax + - read-only \ No newline at end of file diff --git a/benchmarker.sh b/benchmarker.sh new file mode 100755 index 0000000..65e04f0 --- /dev/null +++ b/benchmarker.sh @@ -0,0 +1,264 @@ +#!/bin/bash +export PATH="$HOME"/.local/bin:$PATH + +source scripts/logger.sh /tmp/benchmarker-tui.log +source scripts/ui_utils.sh + +ANSIBLE_LOG_FILE=/tmp/ansible-playbook-output.log +rm "$ANSIBLE_LOG_FILE" > /dev/null 2>&1 & + +verify-prerequisites() { + log-info "Verifying prerequisites" + declare prerequisites=(whiptail jq dialog) + + if ! (aws sts get-caller-identity > /dev/null 2>&1); then + log-error "Must be logged into AWS CLI to use this script. Log into the target AWS account and run this script again" true + exit 1 + fi + + for application in "${prerequisites[@]}"; do + if ! (command -v "$application" > /dev/null 2>&1); then + log-warn "$application is required to run this script. Installing $application..." + sudo apt install "$application" + fi + done + + if ! (command -v ansible > /dev/null 2>&1); then + log-warn "Ansible is required to run this script. Installing Ansible..." true + sudo apt install python3-pip + pip3 install --user ansible jmespath + fi + + if ! (ansible-galaxy collection list | grep -i "community.general\|amazon.aws" > /dev/null 2>&1); then + log-warn "Installing Ansible galaxy requirements..." true + cd ansible + ansible-galaxy install -r requirements.yml + cd - + fi +} + +initialize-environment() { + check-sudo-pass "Installing dependencies requires sudo permissions." + if [[ "$?" == 0 ]]; then + log-info "Sudo pass: $PASSWORD" + declare title="Initialize Local Environment" + + if (prompt-yes-no "$title"); then + cd ansible + + ansible-playbook -i inventories/local -e "ansible_become_password=$PASSWORD" --tags init deploy_benchmarker.yml > "$ANSIBLE_LOG_FILE" 2>&1 & + pid=$! + log-info "Running ansible-playbook 'deploy_benchmarker.yml' with the 'init' tag and logging output to file [$ANSIBLE_LOG_FILE]" + + show-tail-box "$title" $pid "$ANSIBLE_LOG_FILE" + + msg-box "Successfully initialized the local environment!" + log-info "Successfully initialized the local environment" + + cd - + fi + fi + + main-menu +} + +deploy-and-run-benchmarkers() { + declare title="Deploy and Run Benchmarkers" + + if (prompt-yes-no "$title"); then + if [[ -z $VPC_ID ]]; then + prompt-for-vpc-id + fi + + cd ansible + + ansible-playbook -i inventories/local -e vpc_id="$VPC_ID" deploy_benchmarker.yml > "$ANSIBLE_LOG_FILE" 2>&1 & + pid=$! + log-info "Running ansible-playbook 'deploy_benchmarker.yml' with no tags and logging output to file [$ANSIBLE_LOG_FILE]" + + show-tail-box "$title" $pid "$ANSIBLE_LOG_FILE" + + msg-box "Successfully deployed and ran benchmarkers!" + log-info "Successfully deployed and ran benchmarkers" + + cd - + fi + + main-menu +} + +destroy-all() { + declare title="Destroy Everything (Clean Slate)" + + if (prompt-yes-no "$title"); then + cd ansible + + ansible-playbook -i inventories/local --tags 'destroy,destroy_key_pair' deploy_benchmarker.yml > "$ANSIBLE_LOG_FILE" 2>&1 & + pid=$! + log-info "Running ansible-playbook 'deploy_benchmarker.yml' with [destroy,destroy_key_pair] tags and logging output to file [$ANSIBLE_LOG_FILE]" + + show-tail-box "$title" $pid "$ANSIBLE_LOG_FILE" + + msg-box "Successfully destroyed everything!" + log-info "Successfully destroyed everything" + + cd - + fi + + main-menu +} + +randomly-populate-dynamodb() { + declare title="Populate DynamoDB with Random Data" + + if (prompt-yes-no "$title"); then + ./scripts/randomly-generate-high-velocity-data.sh /tmp/dynamodb-population.log & + pid=$! + log-info "Running randomly-generate-high-velocity-data script and logging to [$ANSIBLE_LOG_FILE]" + + show-tail-box "$title" $pid "$ANSIBLE_LOG_FILE" + + msg-box "Successfully populated DynamoDB with random data!" + log-info "Successfully populated DynamoDB with random data" + fi + + main-menu +} + +custom-selections() { + declare title="Customize What to Run (Advanced Mode)" + declare choices + declare tags="" + + choices=$(whiptail --separate-output --checklist --fb "$title" "$BOX_HEIGHT" "$BOX_WIDTH" 13 \ + "PREREQUISITES" "Install Prerequisites for Local Machine" OFF \ + "INITIALIZE_ELK" "Initialize Local Elastic Stack" OFF \ + "START_ELK" "Start Local Elastic Stack" OFF \ + "DEPLOY_CDK" "Deploy CDK" OFF \ + "UPLOAD_BIN" "Upload Benchmarker binaries" OFF \ + "RUN_BENCHMARKERS" "Run Benchmarkers" OFF \ + "STOP_ELK" "Stop Local Elastic Stack" OFF \ + "DESTROY" "Destroy Everything except the SSH key" OFF \ + "DESTROY_KEY" "Destroy the SSK Key" OFF \ + "RUN_DYNAMODB" "Run the DynamoDB Benchmarkers" OFF \ + "RUN_DAX" "Run the DAX Benchmarkers" OFF \ + "RUN_CRUD" "Run the CRUD benchmarks for both the DynamoDB and DAX benchmarkers" OFF \ + "RUN_READ_ONLY" "Run the READ-ONLY benchmarks for both the DynamoDB and DAX benchmarkers" OFF 3>&2 2>&1 1>&3) + + if [[ -n $choices ]]; then + for choice in $choices; do + case "$choice" in + "PREREQUISITES") + tags+="prerequisites" + ;; + "INITIALIZE_ELK") + tags+="init_elk" + ;; + "START_ELK") + tags+="elk" + ;; + "DEPLOY_CDK") + tags+="cdk" + ;; + "UPLOAD_BIN") + tags+="upload" + ;; + "RUN_BENCHMARKERS") + tags+="run" + if [[ -z $VPC_ID ]]; then + prompt-for-vpc-id + fi + ;; + "STOP_ELK") + tags+="stop_elk" + ;; + "DESTROY") + tags+="destroy" + ;; + "DESTROY_KEY") + tags+="destroy_key_pair" + ;; + "RUN_DYNAMODB") + tags+="dynamodb" + ;; + "RUN_DAX") + tags+="dax" + ;; + "RUN_CRUD") + tags+="crud" + ;; + "RUN_READ_ONLY") + tags+="read-only" + ;; + esac + done + + + if (prompt-yes-no "$title"); then + cd ansible + + ansible-playbook -i inventories/local --tags "$tags" deploy_benchmarker.yml > "$ANSIBLE_LOG_FILE" 2>&1 & + pid=$! + log-info "Running ansible-playbook 'deploy_benchmarker.yml' with [$tags] tags and logging output to file [$ANSIBLE_LOG_FILE]" + + show-tail-box "$title" $pid "$ANSIBLE_LOG_FILE" + + msg-box "Successfully ran custom tasks!" + log-info "Successfully ran custom tasks" + + cd - + fi + fi + + main-menu +} + +prompt-for-vpc-id() { + readarray -t vpc_arr < <(aws ec2 describe-vpcs | jq -r '.Vpcs[] | "\(.VpcId) \((.Tags[]? | select(.Key | contains("Name")) | .Value) // "")"' | awk '{print($1, $2 == "" ? "-" : $2);}') + declare prompt="" + for item in "${vpc_arr[@]}"; do + prompt+="$item OFF " + done + + VPC_ID=$(whiptail --fb --title "Select VPC" --radiolist "Select which VPC to use to deploy resources into" "$BOX_HEIGHT" "$BOX_WIDTH" "${vpc_arr[@]}" $prompt 3>&2 2>&1 1>&3) +} + +main-menu() { + declare choice + choice=$(whiptail --fb --title "DynamoDB + DAX Benchmarker" --menu "Select an action" "$BOX_HEIGHT" "$BOX_WIDTH" 6 \ + "I" "(I)nitialize local environment" \ + "D" "(D)eploy and Run benchmarkers" \ + "W" "(W)ipe away everything (Clean Slate)" \ + "R" "(R)andomly populate DynamoDB" \ + "C" "(C)ustom (Advanced)" \ + "X" "E(x)it" 3>&2 2>&1 1>&3) + + case $choice in + "I") + initialize-environment + ;; + "D") + deploy-and-run-benchmarkers + ;; + "W") + destroy-all + ;; + "R") + randomly-populate-dynamodb + ;; + "C") + msg-box "This is for advanced users only! Be sure you know what you're doing, as running some things at the same time can cause problems (like destroy and deploy)!" + custom-selections + ;; + "X") + clear + exit 0 + ;; + esac +} + +verify-prerequisites + +while :; do + main-menu +done \ No newline at end of file diff --git a/cdk/.gitignore b/cdk/.gitignore new file mode 100644 index 0000000..f60797b --- /dev/null +++ b/cdk/.gitignore @@ -0,0 +1,8 @@ +*.js +!jest.config.js +*.d.ts +node_modules + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/cdk/.npmignore b/cdk/.npmignore new file mode 100644 index 0000000..c1d6d45 --- /dev/null +++ b/cdk/.npmignore @@ -0,0 +1,6 @@ +*.ts +!*.d.ts + +# CDK asset staging directory +.cdk.staging +cdk.out diff --git a/cdk/README.md b/cdk/README.md new file mode 100644 index 0000000..b507671 --- /dev/null +++ b/cdk/README.md @@ -0,0 +1,88 @@ +# DynamoDB + DAX Benchmarker CDK + +This CDK project deploys a DynamoDB table with a DAX cluster on top of it, and an EC2 instance to act as a bastion host for running benchmarking tests agasint DAX. + +By default, the name of the DynamoDB table that is created is `$USER-high-velocity-table`. +By default, the name of the SSH key that is created for you is `$USER-dax-pair` + +It should be noted that due to a bug in CDK, if you destroy the stack, you'll have to manually delete the SubnetGroup in DAX once everything else is deleted. + +## Prerequisites +You must be logged into the AWS CLI prior to running the CDK. Ensure you're logged into your target AWS account by running +`aws sts get-caller-identity`. + +## Getting started +[NodeJS](https://nodejs.org/en) is required for development. Install NodeJS using the following commands, if it is +not already installed: + +### Installing NodeJS + +#### Windows +NodeJS can be installed on Windows using the [Chocolatey](https://chocolatey.org) package manager. If Chocolatey is not yet +installed on your system, first install it in a privileged PowerShell: +```powershell +Set-ExecutionPolicy Bypass -Scope Process -Force; + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072; + iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) +``` + +Then, in a _non-privileged_ PowerShell session, install node: +```powershell +choco install nodejs +``` + +#### Linux +NodeJS can be installed on Linux using [NVM](https://github.com/nvm-sh/nvm). First, install NVM: +```shell +curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash +``` + +**Note:** The installation command was _not_ run with `sudo`. This is intentional, because if you install with `sudo`, then +`sudo` permissions will be required to install any and all new dependencies! You should avoid installing Node for the root +user! + + +Then, in order to use NVM to install NodeJS, you need to either restart your current shell session, or run the following: +```shell +export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" +``` + +Now, install NodeJS: +```shell +nvm install node +``` + +### Installing dependent libraries + +Once node is installed, run the following commands to install the NPM libraries: + +```shell +cd cdk +npm install -g aws-cdk +npm install -g typescript --save-dev +npm install +``` + +## CDK Arguments +This application depends on a few additional parameters in order to run. They can be specified in one of two ways: environment variables, or via the `-c` argument of the `cdk` command. + +**Important:** Only one environment variable is required by the application, regardless of which parameter specification method you choose: `AWS_REGION`. + +The following is a table of the **required** parameters for running the CDK + +| Parameter Name | Environment Variable Name | Description | +|----------------|---------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `vpcId` | `VPC_ID` | The VPC ID you wish to deploy all of the stack's components into | +| `localIp` | `LOCAL_IP` | Your local IP; Used to allow SSH and Elasticsearch access in the EC2 security group | +| `sshKeyName` | `SSH_KEY_NAME` | The key name of your ssh key to allow you access to your EC2 instance. This should only be the name of the `.pem` file, and should not include the `.pem` extension. | +| `awsAccount` | `AWS_ACCOUNT` | The account ID of your AWS account. | +| `awsRegion` | `AWS_REGION` | The AWS region to deploy this stack and its components into | + +### Optional Parameters +It is sometimes necessary to tweak the deployment a bit for different use cases. The CDK can be tweaked with the following parameters: + +| Parameter Name | Default Value | Description | +|-----------------|-----------------------|-------------------------------------------------------------------------------------------------------------------------| +| `baseTableName` | `high-velocity-table` | This is the base name for the table. All tables created by the stack will be prefixed with `$USER` to prevent conflicts | + diff --git a/cdk/bin/dynamodb-dax-benchmarker.ts b/cdk/bin/dynamodb-dax-benchmarker.ts new file mode 100644 index 0000000..3abf869 --- /dev/null +++ b/cdk/bin/dynamodb-dax-benchmarker.ts @@ -0,0 +1,69 @@ +#!/usr/bin/env node +import 'source-map-support/register'; +import * as cdk from 'aws-cdk-lib'; +import {EnvironmentProps} from '../lib/types'; +import { DaxBenchmarkingStack } from '../lib/dax-benchmarking-stack'; + +const app = new cdk.App(); +const user = process.env.USER || ''; +let vpcId = app.node.tryGetContext('vpcId'); +if (!vpcId) { + if (!process.env.VPC_ID) { + throw new Error('vpcId is a required parameter. Specify it with `-c vpcId=someId`, or by setting the VPC_ID environment variable'); + } else { + vpcId = process.env.VPC_ID + } +} + +let localIp = app.node.tryGetContext('localIp'); +if (!localIp) { + if (!process.env.LOCAL_IP) { + throw new Error('Local IP is a required parameter. Specify it with `-c localIp=XXX.XXX.XXX.XXX`, or by setting the LOCAL_IP environment variable'); + } else { + localIp = process.env.LOCAL_IP + } +} + +let sshKeyName = app.node.tryGetContext('sshKeyName'); +if (!sshKeyName) { + if (!process.env.SSH_KEY_NAME) { + sshKeyName = `${user}-dax-pair`; + } else { + sshKeyName = process.env.SSH_KEY_NAME; + } +} + +let awsAccount = app.node.tryGetContext('awsAccount'); +if (!awsAccount) { + if (!process.env.AWS_ACCOUNT) { + throw new Error('awsAccount is a required parameter. Specify it with `-c awsAccount=1234567890`, or by setting the AWS_ACCOUNT environment variable.'); + } else { + awsAccount = process.env.AWS_ACCOUNT; + } +} + +let awsRegion = app.node.tryGetContext('awsRegion'); +if (!awsRegion) { + if (!process.env.AWS_REGION) { + throw new Error('The `AWS_REGION` environment variable was not set. It must be set in order to use this application.'); + } else { + awsRegion = process.env.AWS_REGION + } +} + +let baseTableName = app.node.tryGetContext('baseTableName'); +if (!baseTableName) { + baseTableName = 'high-velocity-table' +} + +const environmentProps: EnvironmentProps = { + env: { account: awsAccount, region: awsRegion }, + baseTableName, + removalPolicy: cdk.RemovalPolicy.DESTROY, + user, + vpcId, + localIp, + sshKeyName +}; + +new DaxBenchmarkingStack(app, `${user}-dax-benchmark-stack`, environmentProps); diff --git a/cdk/jest.config.js b/cdk/jest.config.js new file mode 100644 index 0000000..08263b8 --- /dev/null +++ b/cdk/jest.config.js @@ -0,0 +1,8 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/test'], + testMatch: ['**/*.test.ts'], + transform: { + '^.+\\.tsx?$': 'ts-jest' + } +}; diff --git a/cdk/lib/bastion-host.ts b/cdk/lib/bastion-host.ts new file mode 100644 index 0000000..625dde8 --- /dev/null +++ b/cdk/lib/bastion-host.ts @@ -0,0 +1,50 @@ +import { Tags } from "aws-cdk-lib"; +import { Construct } from "constructs"; +import { EnvironmentProps } from "./types"; +import { Instance, InstanceClass, InstanceSize, InstanceType, MachineImage, Peer, Port, SecurityGroup, SubnetType, Vpc } from "aws-cdk-lib/aws-ec2"; +import { IRole, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam"; + +export class DaxBastionHost extends Construct { + public readonly instanceRole: IRole; + public readonly instance: Instance; + + constructor(scope: Construct, id: string, environmentProps: EnvironmentProps, daxSecurityGroup: SecurityGroup) { + super(scope, id); + + Tags.of(this).add('Application', 'dynamodb-dax-benchmarker'); + + const { removalPolicy, user, vpcId, localIp, sshKeyName } = environmentProps; + const localIpCidr = `${localIp}/32`; + + const vpc = Vpc.fromLookup(this, 'Vpc', { vpcId }); + + const bastionHostSecurityGroup = new SecurityGroup(this, `${user}-dax-sg`, { + vpc, + description: `Allow SSH, Elasticsearch, and DAX access for ${user}`, + securityGroupName: `${user}-dax-bastion-host-sg` + }); + bastionHostSecurityGroup.applyRemovalPolicy(removalPolicy); + bastionHostSecurityGroup.addIngressRule(Peer.ipv4(localIpCidr), Port.tcp(22), "Allow SSH access to this instance from the users public IP"); + bastionHostSecurityGroup.addIngressRule(Peer.ipv4(localIpCidr), Port.tcp(9200), "Allow the host to communicate with the users locally running Elasticsearch cluster"); + bastionHostSecurityGroup.addIngressRule(daxSecurityGroup, Port.allTraffic()); + daxSecurityGroup.addIngressRule(bastionHostSecurityGroup, Port.allTraffic()); + + this.instanceRole = new Role(this, `${user}-bastion-role`, { + roleName: `${user}-bastion-role`, + assumedBy: new ServicePrincipal('ec2.amazonaws.com'), + }); + this.instanceRole.applyRemovalPolicy(removalPolicy); + + this.instance = new Instance(this, `${user}-dax-bastion-host`, { + vpc, + instanceType: InstanceType.of(InstanceClass.T2, InstanceSize.SMALL), + machineImage: MachineImage.latestAmazonLinux2023(), + instanceName: `${user}-dax-bastion-host`, + keyName: sshKeyName, + vpcSubnets: vpc.selectSubnets({ subnetType: SubnetType.PUBLIC }), + securityGroup: bastionHostSecurityGroup, + role: this.instanceRole + }); + this.instance.applyRemovalPolicy(removalPolicy); + } +} diff --git a/cdk/lib/dax-benchmarking-stack.ts b/cdk/lib/dax-benchmarking-stack.ts new file mode 100644 index 0000000..917868f --- /dev/null +++ b/cdk/lib/dax-benchmarking-stack.ts @@ -0,0 +1,89 @@ +import { Construct } from "constructs"; +import { EnvironmentProps } from "./types"; +import { CfnOutput, Stack, Tags } from "aws-cdk-lib"; +import { CfnCluster, CfnSubnetGroup } from "aws-cdk-lib/aws-dax"; +import { Effect, PolicyDocument, PolicyStatement, Role, ServicePrincipal } from "aws-cdk-lib/aws-iam"; +import { SecurityGroup, SubnetType, Vpc } from "aws-cdk-lib/aws-ec2"; +import { DynamoDbBenchmarkTable } from "./dynamodb"; +import { DaxBastionHost } from "./bastion-host"; + +export class DaxBenchmarkingStack extends Stack { + constructor(scope: Construct, id: string, environmentProps: EnvironmentProps) { + super(scope, id, environmentProps); + + Tags.of(this).add('Application', 'dynamodb-dax-benchmarker'); + + const { user, removalPolicy, vpcId } = environmentProps; + const { table } = new DynamoDbBenchmarkTable(this, `${user}-dynamodb-benchmark-table`, environmentProps); + + const vpc = Vpc.fromLookup(this, 'Vpc', { vpcId }); + + const daxSecurityGroup = new SecurityGroup(this, `${user}-dax-sg`, { + vpc, + securityGroupName: `${user}-dax-sg` + }); + daxSecurityGroup.applyRemovalPolicy(removalPolicy); + + const { instanceRole, instance } = new DaxBastionHost(this, `${user}-dax-bastion-host`, environmentProps, daxSecurityGroup); + + const daxClusterName = `${user}-high-velocity`; + const daxFullAccessPolicy = new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + "dynamodb:BatchGetItem", + "dynamodb:GetItem", + "dynamodb:Query", + "dynamodb:Scan", + "dynamodb:BatchWriteItem", + "dynamodb:DeleteItem", + "dynamodb:PutItem", + "dynamodb:UpdateItem", + "dynamodb:DescribeLimits", + "dynamodb:DescribeTimeToLive", + "dynamodb:DescribeTable", + "dynamodb:ListTables" + ], + resources: [table.tableArn] + }); + + const daxServiceRole = new Role(this, `${daxClusterName}-role`, { + assumedBy: new ServicePrincipal("dax.amazonaws.com"), + inlinePolicies: { + DAXFullAccess: new PolicyDocument({ + statements: [daxFullAccessPolicy] + }) + } + }); + daxServiceRole.applyRemovalPolicy(removalPolicy); + + instanceRole.addToPrincipalPolicy(daxFullAccessPolicy); + + const subnetGroup = new CfnSubnetGroup(this, `${user}-dax-subnet-group`, { + subnetIds: vpc.selectSubnets({ + subnetType: SubnetType.PRIVATE_ISOLATED + }).subnetIds, + subnetGroupName: `${user}-dax-subnet-group`, + }); + subnetGroup.applyRemovalPolicy(removalPolicy); + + const daxCluster = new CfnCluster(this, daxClusterName, { + iamRoleArn: daxServiceRole.roleArn, + nodeType: 'dax.r5.large', + replicationFactor: 3, + securityGroupIds: [daxSecurityGroup.securityGroupId], + subnetGroupName: subnetGroup.subnetGroupName, + availabilityZones: vpc.availabilityZones, + clusterEndpointEncryptionType: 'TLS', + clusterName: daxClusterName, + sseSpecification: { + sseEnabled: true, + } + }); + daxCluster.applyRemovalPolicy(removalPolicy); + daxCluster.addDependency(subnetGroup); + + new CfnOutput(this, 'DaxEndpoint', { value: daxCluster.attrClusterDiscoveryEndpointUrl }); + new CfnOutput(this, 'InstanceId', { value: instance.instanceId }); + new CfnOutput(this, 'InstancePublicIp', { value: instance.instancePublicIp }); + } +} diff --git a/cdk/lib/dynamodb.ts b/cdk/lib/dynamodb.ts new file mode 100644 index 0000000..c8eb457 --- /dev/null +++ b/cdk/lib/dynamodb.ts @@ -0,0 +1,27 @@ +import {Tags} from "aws-cdk-lib"; +import {Construct} from "constructs"; +import {EnvironmentProps} from "./types"; +import {AttributeType, BillingMode, Table} from "aws-cdk-lib/aws-dynamodb"; + +export class DynamoDbBenchmarkTable extends Construct { + public readonly table: Table; + + constructor(scope: Construct, id: string, environmentProps: EnvironmentProps) { + super(scope, id); + + Tags.of(this).add('Application', 'dynamodb-dax-benchmarker'); + + const { baseTableName, removalPolicy, user } = environmentProps; + const tableName = `${user}-${baseTableName}`; + + this.table = new Table(this, tableName, { + partitionKey: { + name: 'id', + type: AttributeType.STRING + }, + tableName, + removalPolicy, + billingMode: BillingMode.PAY_PER_REQUEST + }); + } +} \ No newline at end of file diff --git a/cdk/lib/types.ts b/cdk/lib/types.ts new file mode 100644 index 0000000..a0cb607 --- /dev/null +++ b/cdk/lib/types.ts @@ -0,0 +1,10 @@ +import {RemovalPolicy, StackProps} from "aws-cdk-lib"; + +export interface EnvironmentProps extends StackProps { + readonly baseTableName: string + readonly removalPolicy: RemovalPolicy + readonly user: string + readonly vpcId: string + readonly localIp: string + readonly sshKeyName: string +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e91f7b8 --- /dev/null +++ b/go.mod @@ -0,0 +1,40 @@ +module github.com/Dark-Alex-17/dynamodb-benchmarker + +go 1.20 + +require ( + github.com/aws/aws-cdk-go/awscdk/v2 v2.88.0 + github.com/aws/aws-dax-go v1.2.12 + github.com/aws/aws-sdk-go v1.44.301 + github.com/aws/constructs-go/constructs/v10 v10.2.69 + github.com/aws/jsii-runtime-go v1.85.0 + github.com/elastic/go-elasticsearch/v8 v8.8.2 + github.com/google/uuid v1.3.0 + github.com/sirupsen/logrus v1.9.3 + gopkg.in/loremipsum.v1 v1.1.2 +) + +require ( + github.com/Masterminds/semver/v3 v3.2.1 // indirect + github.com/cdklabs/awscdk-asset-awscli-go/awscliv1/v2 v2.2.200 // indirect + github.com/cdklabs/awscdk-asset-kubectl-go/kubectlv20/v2 v2.1.2 // indirect + github.com/cdklabs/awscdk-asset-node-proxy-agent-go/nodeproxyagentv5/v2 v2.0.165 // indirect + github.com/fatih/color v1.15.0 // indirect + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.19 // indirect + github.com/spf13/pflag v1.0.5 // indirect + github.com/yuin/goldmark v1.4.13 // indirect + golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect + golang.org/x/mod v0.12.0 // indirect + golang.org/x/tools v0.11.0 // indirect +) + +require ( + github.com/antlr/antlr4 v0.0.0-20181218183524-be58ebffde8e // indirect + github.com/elastic/elastic-transport-go/v8 v8.3.0 // indirect + github.com/gofrs/uuid v3.3.0+incompatible // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/spf13/cobra v1.7.0 + golang.org/x/sys v0.10.0 // indirect +) diff --git a/pkg/app/main.go b/pkg/app/main.go new file mode 100644 index 0000000..4dec547 --- /dev/null +++ b/pkg/app/main.go @@ -0,0 +1,245 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "math/rand" + "os" + "strconv" + "strings" + "time" + + "github.com/Dark-Alex-17/dynamodb-benchmarker/pkg/models" + "github.com/Dark-Alex-17/dynamodb-benchmarker/pkg/simulators" + "github.com/Dark-Alex-17/dynamodb-benchmarker/pkg/utils" + "github.com/aws/aws-dax-go/dax" + "github.com/aws/aws-sdk-go/aws" + "github.com/aws/aws-sdk-go/aws/credentials" + "github.com/aws/aws-sdk-go/aws/session" + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/elastic/go-elasticsearch/v8" + log "github.com/sirupsen/logrus" + "github.com/spf13/cobra" +) + +var concurrentSimulations, buffer, attributes, duration int +var username, password, index, endpoint, tableName string +var readOnly bool + +func main() { + rootCmd := &cobra.Command{ + Use: "dax-benchmarker", + Short: "A CLI tool for simulating heavy usage against DAX and publishing metrics to an Elastic Stack for analysis", + RunE: func(cmd *cobra.Command, args []string) error { + if err := validateFlags(); err != nil { + return err + } + + execute() + return nil + }, + } + rootCmd.PersistentFlags().IntVarP(&concurrentSimulations, "concurrent-simulations", "c", 1000, "The number of concurrent simulations to run") + rootCmd.PersistentFlags().IntVarP(&buffer, "buffer", "b", 500, "The buffer size of the Elasticsearch goroutine's channel") + rootCmd.PersistentFlags().IntVarP(&attributes, "attributes", "a", 5, "The number of attributes to use when populating and querying the DynamoDB table; minimum value of 1") + rootCmd.PersistentFlags().IntVarP(&duration, "duration", "d", 1800, "The length of time (in seconds) to run the benchmark for") + rootCmd.PersistentFlags().StringVarP(&username, "username", "u", "elastic", "Local Elasticsearch cluster username") + rootCmd.PersistentFlags().StringVarP(&password, "password", "p", "changeme", "Local Elasticsearch cluster password") + rootCmd.PersistentFlags().StringVarP(&index, "index", "i", "dax", "The Elasticsearch Index to insert data into") + rootCmd.PersistentFlags().StringVarP(&tableName, "table", "t", fmt.Sprintf("%s-high-velocity-table", os.Getenv("USER")), "The DynamoDB table to perform operations against") + rootCmd.PersistentFlags().StringVarP(&endpoint, "endpoint", "e", "", "The DAX endpoint to hit when running simulations (assumes secure endpoint, so do not specify port)") + rootCmd.PersistentFlags().BoolVarP(&readOnly, "read-only", "r", false, "Whether to run a read-only scenario for benchmarking") + + if err := rootCmd.Execute(); err != nil { + log.Errorf("Something went wrong parsing CLI args and executing the client: %v", err) + } +} + +func validateFlags() error { + if len(endpoint) == 0 { + daxEndpointEnvironmentVariable := os.Getenv("DAX_ENDPOINT") + if len(daxEndpointEnvironmentVariable) == 0 { + return errors.New("a DAX endpoint must be specified either via -e, --endpoint or via the DAX_ENDPOINT environment variable") + } else { + endpoint = daxEndpointEnvironmentVariable + } + } + + if attributes < 1 { + return errors.New("the number of attributes cannot be lower than 1") + } + + if len(os.Getenv("AWS_REGION")) == 0 { + return errors.New("an AWS region must be specified using the AWS_REGION environment variable") + } + + return nil +} + +func execute() { + esChan := make(chan models.DynamoDbSimulationMetrics, buffer) + defer close(esChan) + daxEndpoint := fmt.Sprintf("%s:9111", endpoint) + region := os.Getenv("AWS_REGION") + sess := session.Must(session.NewSession(&aws.Config{ + Credentials: credentials.NewChainCredentials([]credentials.Provider{&credentials.EnvProvider{}}), + Endpoint: &daxEndpoint, + Region: ®ion, + })) + + if _, err := sess.Config.Credentials.Get(); err != nil { + log.Errorf("credentials were not loaded! %v+", err) + } + + client, err := dax.NewWithSession(*sess) + if err != nil { + log.Errorf("unable to initialize dax client %v", err) + } + + partitionKeys, err := scanAllPartitionKeys(client) + if err != nil { + log.Errorf("Unable to fetch partition keys! Simulation failed! %v+", err) + } + + go startElasticsearchPublisher(esChan) + + for i := 0; i < concurrentSimulations; i++ { + go simulationLoop(esChan, client, partitionKeys) + } + + duration, err := time.ParseDuration(strconv.Itoa(duration) + "s") + if err != nil { + log.Errorf("Unable to create duration from the provided time: %v", err) + return + } + + <-time.After(duration) +} + +func startElasticsearchPublisher(c <-chan models.DynamoDbSimulationMetrics) { + config := elasticsearch.Config{ + Addresses: []string{ + "http://localhost:9200", + }, + Username: username, + Password: password, + } + esClient, err := elasticsearch.NewClient(config) + if err != nil { + log.Errorf("unable to initialize elasticsearch client %v", err) + } + + mapping := `{ + "properties": { + "timestamp": { + "type": "date" + } + } + }` + + log.Infof("Setting the explicit mappings for the %s index", index) + if _, err := esClient.Indices.Create(index); err != nil { + log.Warnf("Unable to create the %s index. Encountered the following error: %v", index, err) + } + + if _, err := esClient.Indices.PutMapping([]string{index}, strings.NewReader(mapping)); err != nil { + log.Errorf("unable to create mapping for the %s index! %v+", index, err) + } + + for metric := range c { + log.Info("Publishing metrics to Elasticsearch...") + + data, _ := json.Marshal(metric) + _, err := esClient.Index(index, bytes.NewReader(data)) + if err != nil { + log.Error("Was unable to publish metrics to Elasticsearch! Received a non 2XX response") + } else { + log.Info("Successfully published metrics to Elasticsearch") + } + } +} + +func simulationLoop(c chan<- models.DynamoDbSimulationMetrics, client *dax.Dax, partitionKeys []string) { + for { + metrics := new(models.DynamoDbSimulationMetrics) + metrics.Successful = true + metrics.Timestamp = time.Now().UnixNano() / 1e6 + startTime := time.Now() + + if readOnly { + log.Info("Running a read-only simulation...") + metrics.Scenario = models.ScenarioReadOnly.String() + runReadOnlySimulation(client, metrics, partitionKeys) + } else { + log.Info("Running a CRUD simulation...") + metrics.Scenario = models.ScenarioCrud.String() + runCrudSimulation(client, metrics, partitionKeys) + } + + log.Info("Simulation completed successfully!") + + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.SimulationTime = &millisecondDuration + + log.Infof("Metrics: %v+", metrics) + + c <- *metrics + } +} + +func runReadOnlySimulation(client *dax.Dax, metrics *models.DynamoDbSimulationMetrics, partitionKeys []string) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + time.Sleep(time.Duration(r.Intn(16))) + + metrics.Operation = models.DynamoRead.String() + simulators.SimulateReadOperation(client, tableName, partitionKeys, metrics) +} + +func runCrudSimulation(client *dax.Dax, metrics *models.DynamoDbSimulationMetrics, partitionKeys []string) { + r := rand.New(rand.NewSource(time.Now().UnixNano())) + operation := r.Intn(3) + log.Infof("Operation number: %d", operation) + + switch operation { + case int(models.DynamoRead): + metrics.Operation = models.DynamoRead.String() + simulators.SimulateReadOperation(client, tableName, partitionKeys, metrics) + case int(models.DynamoWrite): + metrics.Operation = models.DynamoWrite.String() + simulators.SimulateWriteOperation(client, tableName, attributes, metrics) + case int(models.DynamoUpdate): + metrics.Operation = models.DynamoUpdate.String() + simulators.SimulateUpdateOperation(client, tableName, attributes, metrics) + } +} + +func scanAllPartitionKeys(client *dax.Dax) ([]string, error) { + log.Info("Fetching a large list of partition keys to randomly read...") + projectionExpression := "id" + var limit int64 = 10000 + + response, err := client.Scan(&dynamodb.ScanInput{ + TableName: &tableName, + Limit: &limit, + ProjectionExpression: &projectionExpression, + }) + + if err != nil { + log.Errorf("Unable to fetch partition keys! %v", err) + return []string{}, err + } else { + log.Info("Fetched partition keys!") + keys := make([]string, 100) + + for _, itemsMap := range response.Items { + keys = append(keys, *utils.MapValues(itemsMap)[0].S) + } + + log.Infof("Found a total of %d keys", len(keys)) + + return keys, nil + } +} diff --git a/pkg/models/models.go b/pkg/models/models.go new file mode 100644 index 0000000..3a26cb6 --- /dev/null +++ b/pkg/models/models.go @@ -0,0 +1,89 @@ +package models + +import ( + "fmt" + "math/rand" + "strconv" + "time" + + "github.com/aws/aws-sdk-go/service/dynamodb" + "github.com/google/uuid" + "gopkg.in/loremipsum.v1" +) + +type DynamoOperation int + +const ( + DynamoRead DynamoOperation = iota + DynamoWrite + DynamoUpdate +) + +func (d DynamoOperation) String() string { + switch d { + case DynamoRead: + return "read" + case DynamoWrite: + return "write" + case DynamoUpdate: + return "update" + default: + return "read" + } +} + +type Scenario int + +const ( + ScenarioCrud Scenario = iota + ScenarioReadOnly +) + +func (s Scenario) String() string { + switch s { + case ScenarioCrud: + return "crud" + case ScenarioReadOnly: + return "readOnly" + default: + return "crud" + } +} + +type BenchmarkingItem map[string]*dynamodb.AttributeValue + +func NewBenchmarkingItem(attributes int) BenchmarkingItem { + benchmarkingItem := make(map[string]*dynamodb.AttributeValue) + r := rand.New(rand.NewSource(time.Now().UnixNano())) + loremIpsumGenerator := loremipsum.NewWithSeed(time.Now().UnixNano()) + id := uuid.New().String() + benchmarkingItem["id"] = &dynamodb.AttributeValue{S: &id} + + for i := 0; i < attributes; i++ { + switch i % 2 { + case 1: + float := fmt.Sprintf("%.2f", r.Float64()*32.00) + benchmarkingItem[strconv.Itoa(i)] = &dynamodb.AttributeValue{N: &float} + default: + sentence := loremIpsumGenerator.Sentence() + benchmarkingItem[strconv.Itoa(i)] = &dynamodb.AttributeValue{S: &sentence} + } + } + + return benchmarkingItem +} + +type DynamoDbSimulationMetrics struct { + Operation string `json:"operation"` + Timestamp int64 `json:"timestamp"` + Successful bool `json:"successful"` + Scenario string `json:"scenario"` + SimulationTime *float64 `json:"simulationTime,omitempty"` + ReadTime *float64 `json:"readTime,omitempty"` + WriteTime *float64 `json:"writeTime,omitempty"` + WriteItemConfirmationTime *float64 `json:"writeItemConfirmationTime,omitempty"` + UpdateTime *float64 `json:"updateItem,omitempty"` + UpdateItemConfirmationTime *float64 `json:"updateItemConfirmationTime,omitempty"` + DeleteTime *float64 `json:"deleteTime,omitempty"` + DeleteItemConfirmationTime *float64 `json:"deleteItemConfirmationTime,omitempty"` +} diff --git a/pkg/simulators/operations.go b/pkg/simulators/operations.go new file mode 100644 index 0000000..fe76211 --- /dev/null +++ b/pkg/simulators/operations.go @@ -0,0 +1,110 @@ +package simulators + +import ( + "time" + + "github.com/Dark-Alex-17/dynamodb-benchmarker/pkg/models" + "github.com/aws/aws-dax-go/dax" + "github.com/aws/aws-sdk-go/service/dynamodb" + log "github.com/sirupsen/logrus" +) + +func ReadItem(client *dax.Dax, tableName string, id dynamodb.AttributeValue, metrics *models.DynamoDbSimulationMetrics, recordMetrics bool) (dynamodb.GetItemOutput, error) { + partitionKey := *id.S + startTime := time.Now() + response, err := client.GetItem(&dynamodb.GetItemInput{ + TableName: &tableName, + Key: map[string]*dynamodb.AttributeValue{ + "id": {S: id.S}, + }, + }) + + if recordMetrics { + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.ReadTime = &millisecondDuration + } + + if err != nil { + log.Errorf("Could not fetch item with partition key: %v. %v+", partitionKey, err) + metrics.Successful = false + return dynamodb.GetItemOutput{}, err + } + + if len(response.Item) == 0 { + log.Infof("No items found with partition key: %v", partitionKey) + return dynamodb.GetItemOutput{}, nil + } + + return *response, nil +} + +func UpdateItem(client *dax.Dax, tableName string, id dynamodb.AttributeValue, attributes int, metrics *models.DynamoDbSimulationMetrics) { + updatedItem := models.NewBenchmarkingItem(attributes) + updatedItem["id"] = &id + partitionKey := *id.S + startTime := time.Now() + + _, err := client.PutItem(&dynamodb.PutItemInput{ + TableName: &tableName, + Item: updatedItem, + }) + + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.UpdateTime = &millisecondDuration + + if err != nil { + log.Errorf("Could not update item with partition key: %v. %v+", partitionKey, err) + metrics.Successful = false + } else { + log.Infof("Successfully updated item with partition key: %v", partitionKey) + } +} + +func PutItem(client *dax.Dax, tableName string, attributes int, metrics *models.DynamoDbSimulationMetrics) (models.BenchmarkingItem, error) { + newItem := models.NewBenchmarkingItem(attributes) + partitionKey := *newItem["id"].S + startTime := time.Now() + + _, err := client.PutItem(&dynamodb.PutItemInput{ + TableName: &tableName, + Item: newItem, + }) + + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.WriteTime = &millisecondDuration + + if err != nil { + log.Errorf("Could not put new item with partition key: %v. %v+", partitionKey, err) + metrics.Successful = false + return models.BenchmarkingItem{}, err + } + + log.Infof("Successfully put new item with partition key: %v", partitionKey) + return newItem, nil +} + +func DeleteItem(client *dax.Dax, tableName string, id dynamodb.AttributeValue, metrics *models.DynamoDbSimulationMetrics) { + partitionKey := *id.S + startTime := time.Now() + + _, err := client.DeleteItem(&dynamodb.DeleteItemInput{ + TableName: &tableName, + Key: map[string]*dynamodb.AttributeValue{ + "charger_id": &id, + }, + }) + + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.DeleteTime = &millisecondDuration + + if err != nil { + log.Errorf("Could not delete item with partition key: %v. %v+", partitionKey, err) + metrics.Successful = false + } else { + log.Infof("Successfully deleted item with partition key: %v", partitionKey) + } +} diff --git a/pkg/simulators/simulations.go b/pkg/simulators/simulations.go new file mode 100644 index 0000000..7353895 --- /dev/null +++ b/pkg/simulators/simulations.go @@ -0,0 +1,111 @@ +package simulators + +import ( + "math/rand" + "strings" + "time" + + "github.com/Dark-Alex-17/dynamodb-benchmarker/pkg/models" + "github.com/aws/aws-dax-go/dax" + "github.com/aws/aws-sdk-go/service/dynamodb" + log "github.com/sirupsen/logrus" +) + +func SimulateReadOperation(client *dax.Dax, tableName string, partitionKeys []string, metrics *models.DynamoDbSimulationMetrics) { + log.Info("Performing READ operation...") + r := rand.New(rand.NewSource(time.Now().UnixNano())) + var partitionKey string + for { + partitionKey = partitionKeys[r.Intn(len(partitionKeys))] + if len(strings.TrimSpace(partitionKey)) == 0 { + log.Info("Parition key was empty. Trying again to choose a non-empty partition key") + } else { + break + } + } + id := dynamodb.AttributeValue{S: &partitionKey} + + for i := 0; i < 10; i++ { + log.Infof("Attempt %d: Fetching existing item with partition key: %v", i, partitionKey) + + response, _ := ReadItem(client, tableName, id, metrics, true) + if response.Item["id"] != nil { + log.Infof("Successfully read existing item with partition key: %v", partitionKey) + break + } + + log.Errorf("Unable to find existing item with partition key: %v", partitionKey) + if i == 9 { + log.Errorf("All attempts to fetch the existing item with partition key: %v failed!", partitionKey) + metrics.Successful = false + } + } +} + +func SimulateWriteOperation(client *dax.Dax, tableName string, attributes int, metrics *models.DynamoDbSimulationMetrics) { + log.Info("Performing WRITE operation...") + benchmarkingItem, err := PutItem(client, tableName, attributes, metrics) + if err != nil { + log.Errorf("Unable to complete PUT simulation. %v+", err) + metrics.Successful = false + return + } + + id := *benchmarkingItem["id"] + + AssertItemWasCreated(client, tableName, id, metrics) + + DeleteItem(client, tableName, id, metrics) + + AssertItemWasDeleted(client, tableName, id, metrics) +} + +func SimulateUpdateOperation(client *dax.Dax, tableName string, attributes int, metrics *models.DynamoDbSimulationMetrics) { + log.Info("Performing UPDATE operation...") + newItem, err := PutItem(client, tableName, attributes, metrics) + if err != nil { + log.Errorf("Unable to complete UPDATE simulation. %v+", err) + metrics.Successful = false + return + } + + id := *newItem["id"] + partitionKey := *id.S + attemptsExhausted := false + + AssertItemWasCreated(client, tableName, id, metrics) + UpdateItem(client, tableName, id, attributes, metrics) + + startTime := time.Now() + for i := 0; i < 10; i++ { + log.Infof("Attempt %d: Fetching updated item for partition key: %v...", i, partitionKey) + + updatedItem, err := ReadItem(client, tableName, id, metrics, false) + if err != nil { + log.Errorf("Unable to complete UPDATE simulation. %v+", err) + metrics.Successful = false + return + } + + if *newItem["1"].N != *updatedItem.Item["1"].N { + log.Infof("Confirmed update for partition key: %v", partitionKey) + break + } else { + log.Errorf("Update for partition key %v failed! Values are still equal!", partitionKey) + if i == 9 { + log.Error("Exhausted attempts to fetch updated item!") + metrics.Successful = false + attemptsExhausted = true + } + } + } + + if !attemptsExhausted { + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.UpdateItemConfirmationTime = &millisecondDuration + } + + DeleteItem(client, tableName, id, metrics) + AssertItemWasDeleted(client, tableName, id, metrics) +} diff --git a/pkg/simulators/utils.go b/pkg/simulators/utils.go new file mode 100644 index 0000000..6021a51 --- /dev/null +++ b/pkg/simulators/utils.go @@ -0,0 +1,69 @@ +package simulators + +import ( + "time" + + "github.com/Dark-Alex-17/dynamodb-benchmarker/pkg/models" + "github.com/aws/aws-dax-go/dax" + "github.com/aws/aws-sdk-go/service/dynamodb" + log "github.com/sirupsen/logrus" +) + +func AssertItemWasCreated(client *dax.Dax, tableName string, id dynamodb.AttributeValue, metrics *models.DynamoDbSimulationMetrics) { + partitionKey := *id.S + attemptsExhausted := false + startTime := time.Now() + + for i := 0; i < 10; i++ { + log.Infof("Attempt %d: Fetching newly added item with partition key: %v", i, partitionKey) + + newItem, err := ReadItem(client, tableName, id, metrics, false) + + if err != nil || newItem.Item["id"].S == nil { + log.Errorf("Unable to find new item with partition key: %v", partitionKey) + if i == 9 { + log.Errorf("All attempts to fetch the newly added item with partition key: %v failed!", partitionKey) + attemptsExhausted = true + metrics.Successful = false + } + } else { + log.Infof("Successfully read new item with partition key: %v", partitionKey) + break + } + } + + if !attemptsExhausted { + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.WriteItemConfirmationTime = &millisecondDuration + } +} + +func AssertItemWasDeleted(client *dax.Dax, tableName string, id dynamodb.AttributeValue, metrics *models.DynamoDbSimulationMetrics) { + partitionKey := *id.S + attemptsExhausted := false + startTime := time.Now() + + for i := 0; i < 10; i++ { + log.Infof("Attempt %d: Fetching deleted item with partition key: %v ...", i, partitionKey) + + deletedItem, _ := ReadItem(client, tableName, id, metrics, false) + if deletedItem.Item["id"].S == nil { + log.Infof("Item with partition key: %v was successfully deleted.", partitionKey) + break + } else { + log.Errorf("Item with partition key %v was not deleted as expected!", partitionKey) + if i == 9 { + log.Errorf("All attempts to receive an empty response to verify item with partition key: %v was deleted failed!", partitionKey) + attemptsExhausted = true + metrics.Successful = false + } + } + } + + if !attemptsExhausted { + duration := time.Since(startTime).Microseconds() + millisecondDuration := float64(duration) / 1000 + metrics.DeleteItemConfirmationTime = &millisecondDuration + } +} diff --git a/pkg/utils/utils.go b/pkg/utils/utils.go new file mode 100644 index 0000000..dd18f91 --- /dev/null +++ b/pkg/utils/utils.go @@ -0,0 +1,11 @@ +package utils + +func MapValues[K comparable, V any](inputMap map[K]V) []V { + valuesSlice := make([]V, 0) + + for _, value := range inputMap { + valuesSlice = append(valuesSlice, value) + } + + return valuesSlice +} diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..a2495a8 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,10 @@ +tab_spaces=2 +edition = "2021" +reorder_imports = true +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +reorder_modules = true +merge_derives = true +use_field_init_shorthand = true +format_macro_matchers = true +format_macro_bodies = true diff --git a/screenshots/advanced-mode.png b/screenshots/advanced-mode.png new file mode 100644 index 0000000000000000000000000000000000000000..1adf562cd4b5e9f6d25d2e890f2bab12cd3bae8a GIT binary patch literal 64844 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 literal 0 HcmV?d00001 diff --git a/screenshots/dynamodb-dax-benchmarker.png b/screenshots/dynamodb-dax-benchmarker.png new file mode 100644 index 0000000000000000000000000000000000000000..8d292383546bac1e024c10cdeb2fc0a51660a1fb GIT binary patch literal 35440 zcmeFZcUV*D+CJ)xT|^zFNFN(W=v}&^h=PC+LWh9#F1;pE5YZ?gy@P=C5~-mkB0}iB z2!TXO=p;aZ03mRqd-lw1zdhe|u5+Dp{`&FPVqLP{cfHU3l>2$^!+;k`S0e&#EazJW|OGu!L)E;rRR_}Y1& zKa+GAsMmY`KI`WDQ~BStA2Z!+;m|95zS90HVuUPwD zREhsaiR`ufn$$RMDuD?gu1Fn{Mhjr4>5KjNsSH1~3;N~XzbPmlBmeul@=CD(zpuwH zG%5W1+U1o6_aE2Kr0e_X$3{H^QgAJ=^^PtE=N`Sy4J&*P8$e||Vs zMHZ;KI`$(+zA2XgM{Ayq9@+PG*QA>x|6E%X_UL!3KJ$5T;oLLj((f=hM%GSq|3o*@ zfzzp}BRez6^n15O4~N)Ja?&4xetF?sucCHADptv0tX1MU_Hz^PCWaLV+TYdje~V53 z#q<*W-=CVxkehi%H~IePh0jy9^Qb(y7?({();OqBrJJFgs z0tRNr954zZgbW=#6??CNwBE!udKxg({OZ@MQQYev?RsquhT)efaOwOUhN z?-c)$)_Eg_ADgJ$`TlBaoMLlrtG|55^KJQo=?lWJ6u7*%UjGt$Rw1sWFkAzaI`DA2 zsgocmcKQXDL4a0>&D)_$c$lGX&iAJQGVFoSFULSQpOf*sl|~MpjacP^n+J@bAmy=o z8b|HKfeomAclh_Q8M`6?o}v^vfzD~i*?jCE7yi2J(orF;)N6dr(_bG4$UgnZbXR`t-WD@l1gG{Xs#V3 zN=_IzBcX=axxR`8EO8SiWsHwE&*v*pq2xWA z>fSgvy{}?gIY|+%tJQ6uJBnj7bL~^7CRn140^honPQ@>$G?nMA#__@O;Mvr|mDD14 zBO8p~$RHJE!$;un(RDbF$MYq#{+?2WaSoa|?%}%gxaDXg2IXlt22?BD!~hvFLrW9+ zK5Y=^Ms+^M$9F2-(8A?>6od6hfA6iK6H~kH1vuH?bf`tazu-I$Hsy)>^V<3T3qY^? zaT@daerp1NS8wzVG(Q7G-al#>3E<8v19cdVvg zYvrs}{QY~dvR5xY9R{-v zYED5ts}E}=x^2WyK5Q|e5%r`CZ+(bfhyWNSAd-mA8DgnJ2oj^g0G`!z0rGb+B zH>8G4loMsR84wL|fVb(T9lwHa5f!CVTHA4U{&yEldy%F4ZCV|$vNv#~!l_~rCV?5U zeLehIBiSNylGJ#kFqtc7-<@N28K%ngo)jVGugVJa)QSU_=X`HkAQd8v-Q32Q44HAKdP=M zY={p}7YCRgY5mfxcF2*#qF+(KmZ-F4Zpo#nKrGYPHM@esgC2*9zM-gsftm=wUXRG_ zUgb>qd2<6x$KaLzNxeRK&~}7K(bMkXepdd2v6;HIgT1-j zqoiE=i{jlE`|j*=$@8OQc<{FxHegkqoJL6;hsD4s?GK-pbT#fbgw2&&D}? z{G7HXKDf7;J@fO%p8QU>l?{wvw?!F$b^_C}V^~mh+&Jia#<9=qQaZfsIlKMrd&%;i z%VB+rA>=oVJ z0tvnJgM`$C!*~e_3=rY+HBaYb^49Y+S8ekPE9HJqM9Q#r!I-Wm%4~Kz^Jr%R@_b!_ zWIS)iMb6fF?;t36kv$cH03*p0 zZ$PLt98*KY2#^AqRlgKj8%U6nWt<}2gB(%U7Yhcw+`tI1+lmh~+a@BoCmg%%6tc^u z`g}FtE|ZfErq2q+zX1dh=jYU*`Q`4XJ0iD)GJ#+Hcm!swa!Oh3*QMwg{(YJ_b>E7P zqCbj&4=ow6uFs>`6epfrb$g`y)Br7EkYyeY(1}AtyXpYO2uOvn(7wvLrSoz38jC-> z?f+`67NPEv))fLe^w~REBxKgn`cvh7(k!@GSB6^!$q%c`)on%ee zFP3!gi6|<@Xq0PHs}@D>3C3DE92h2sNvVVq4ps~H)Ij9D4sO}6fzX{aR{d6tx|vYl zw9~KOv%1apE++9B^5l~9-Gd&Hf?yh8li~4K`D3gcz+>0U zL5q7{vf`AYX|9Se^veYUny!$ONSimg&eX0#uhzNvj5%CqeMCNd8M)9NvTNbu)7j|X zuyUHGO~)qR_t2SCqxqO{6j^9ypm&f=fecp5yqKUMhfnMmZO{t^c+NV1jOXs{F|d|i z|MXVjo`gw6HZ0$<`MxZ1L^1MH$Hdx}%lsp^u{4mVO%h=Hz=*mwdbeKBKOcJVLKri! zUnY9q>BX7O@Ktxb!Ei9H9P?IxcXy+XCiwSo?9;JAj{JN50XyK|yK8gTT<6XsJAyA< z?+z&nV1&{Twz>&!zScYCFXd#*8uV^>=bVMrgT+f1Pyv(ek9Sux0k0zU4mxqP>={s> z+XrRHnmRCZMFVU@RQfui6%~H&*C#Dy7gLe>ZC{|J>g4$T8Ys@ zFBKT`y}c1j3W~1`0Z*_S8KFB*Cz>t_zR`s)g)ySFPW`=T80W9(RsLJu zFup?W4d>Bg61z=crH%TYTq|~{_zu2L5}*I`O!M}3^EP>8dkO7HjhZ zQTt2UTR+Otv;XOmvjxzDU8kfo*W>}pav)V6;=7{H);vdgWAg&OM>M|^Nsxm-3t0LM zx>K8pcD{fhLZ5UcH;4a8&H98BZ9cbNz`Yf7Iw$idk-J@Q5r3NFuAf9}vFyZOylget zh}Ze-RQKhIxu@3>VZU-yT{Yi0+^W$baqH%^LrV&$Q9ZV{%TJ#3J;joHRPdb!yFE;<`?$PzmKj`XQNC$r%Jb&6$g$Y z7MJ2K8B0l*X$Jd`?t%iOY!gwpNN2uoH!w{4MslyFXV<*)y8-djBm9E>gFsf=*(QAy zdm&>*f7MlG_OAL^O1HggOJx6kIBe<5?i;zJD(z+cgRB@rD@5q2@=$z%bntFomo8q> zrY_Cbh$8T)`j02sk9LV$TAgxhO_U15!Xy%X4~v9=&T#1b#b!0s8UNu`qV`J#^PhK3pA*ae8Vz)R$_jq$Ww{}#yL2rNJm7{U73r-s6 zb0QLp5RnsZ+Z&=+mJ8QsA;^Z}cgtX#!?ox4w=_X|&e~ht$j45e2PGw#JZ37LMEx+m zH~q+;*INE=HNxq7*iEN>y0sc7`Ljk%UFyvp${T$$Jn?T0oI9)`drs9ornidT^2`^2 zokhC8?%sAigoKvy;>Pc?-G+>kSZ*p&?A(vsx1N8Py|;xi%h8nXog7$`Nq^x&W% z0CRRk4GkU)=a6Z1UCh{r*Fgk5aQeh1=d@eC4_SNX?5TX0m{=7b3G~Q0A?tbUXRfDQ zZG{$WW@F+~;!33)jt)sd>!kMw-{wGzhlY(Iz6ncBLL{eAZKti8+z8dA2oWA5vc~J< zDd(0AwO&GvMUBhEu5FIJE+2;&SCIbox<`!T{=z9W@&tK!Bi4K%-ak98uLOM`g}jfA z!~;VQxA*aq5}66Oaf5HH0SQZ=>I>k>4VsimDB<|&Iia|jTSdKld^asywI;VjMqlI* zOp}YIU*V8rlH2q0RbZx}(7^+myYdbw=81E1^(3xALrD6xjM_P z(i2xmb(uV^Uzy~sklcI3X;?Dk4?kGD_jhPXCn5;?i^`ZP$ndX>8V~h*7ni-t+kh3_ zQ5BCSugwfgwdUSbj*6Nb^8N-$^W2TQbp33#ZUl9kH+&tn-CFwjT@xbbsoXIn?X9%n z!@)i%B+g{hiNfB)U8 zX;a$do|0A?%_H$@fG{wknQ}0)EdQ_bd^Te9S2Q@7X$m?z-v|kim(ro36~GnoelD-5 z$t*k#=16DtkQCoD>|v-Y&ELKeYDmr_HJ4LtiNe(vv=AfD9#46R5(CG@OTw;FyNAa^ z@?w1ng~FWpe75%L3r#_#EIE6tR-vJF#*L(OwQ=@Y!|UHmCYI}oZg=qic5JzdM_(_0 zP7$;1p1Lmz?qyxjs^DKLIZChOv1EH>E(W_fzSQvSnas1i+w!)4czG-BPa&4yZCPSN zyBM2Towt5bmeEumLwqcv{xRzbNr%AhKtkYUzSHOBy%nT0mttyZNtoME zDEhm@;oL|CYS!g@J^Sh@1CfAv#Ee{W8Q4T(sj~p+bB~nwMZjT+eYI05GquahMb58e zU8$+YZF&B6JDhzZN}5}?=uLv~^5@r@$w?bb!TxlP5P&!9Fi}+hZL-y2Jwnii3x^Pb z4UuL7ca~4lqvOavBb!_@aI{7M7ckLb~FagcrvR`EC{ zjPdak2r4>*M=6b%9sYs*vhN4RyXH6`e+Amg; zs`Th}*=x8udzQO^r=BScQ=Q|%S(nmyhaoRqH}YOe-Mw+9QO_5={+;}eUh_x|UkTK} z&>aZVrL_0?v!jwzMH4UU1fNcH4=nkD4e;EGOw2C8Vz*Vw6=3Bp+MQ*3oLVaEucMnT zeZbe@S}c=6pkOO>(O1y)5J08wviH}AU9}3xA6+=UCr!O`w+0XYw=?$D{oNTQ{_Idz z%Kw!~{$EL&|89L&GW?$h{}HYK|2v9L+6N3GM~)art;;dcx$^bDY|in$&-aYS>iw}J ze&Vgmg7gY4hF&iH8}&aJ0`%?ki$}h-Uq)2WH?I9z@RIb)HQTpFo;5$3*gIk;)Jt;j zro!VPc>@OW$6Xl;ir_4;yz(hAUhI)?W3~-F^tw;s^xp#*y(j(+bNOZ!oOK4&Fy-do zQJTLI{^8SVrq=_I!yXqX!A!5^i|cc2N|O>1=D|GISsV29Q#Gc}$Dn_RAF#CjvU3Ozj8Y{%PCou*{G%@oDG$ z8pr+b+ce4@JEoP{_~CQQ*oK}NooOCxuC^T-E=y2{p%ISM41lJ~L-`ctCk#v^v5US)2p@(*NeFZtE~z90X_e8CJv`!%wuVd&4s0Vz#K6lGmos0GX~~* zTiIrS*XQ(K4%anM8{>qvhaKV?r+r8F-;@Ck=02txklKO9IgV;c6aHa(N{xZdO8W7N z{{F=PV(rn^4|U^0z10s#It#``G~*J8oxmx*stQx$GDc6pIHkL1Pxf*aEwPH|B z;%ozY_nXJvLs_gR2DF?tSlq+U*7yO5!Q!C$)Q#AQgqax}BlQKl9fL~`PlH~Bz+nJ& zWzt6q7{_tGZ7W|dUB0$3ahk%ApnsW1V}8^V@UfR}Qz5&*s)ZliVPEpaL;dEB-{l&z z$}2z~ZYfW=m6S%_*43dhJAwhS%G(V4av$F_&u_^Ly$r-5{bZRrDH|9G${nOw)kv6Q%C8(R)O%5Qz z&1#2*n{oT#I#W=nhv0+Lf+DdNF3>Bws>_HD;5HJVoRl;D+5v!tsHPV>6<+2rLTaC6 z?!sL|R_GFyVUNdBW%LxFS@8Lw57CzWj5bxRmyMrT1{HUeCCU~8+&i{b-@PoC-_#kC z1!HlAjAbRPmdx$n^@i3(5g`1X(jo zj69H7TIw^i!4%DoNsp1L#vgywX-jH&5CL$T#(P;vFZaFdTI6dj&nWgIKyig=uB^1 z5Lf3nP>hJiA#DQcshsDX)r;KJ(xb=7FTsjC72-Ym<0}kv0x*Mz1|&SNI`B~GL-Z*UKBl_e z%-=AlU!)SZGgRRLRkV9MYDACWe3t(Oech)zQ!Z|~YKj_2@m+mta;>{Sz&M8u6n^w} z#p91N)*zN!v!+w5rXwpHS1)XpUF3WT;Dc6nKxF}=t7;oeSOUu3ggXpaDngY)a28Q_ zh$=AByNL*xp6syl?XBb*kSo$;I08VrOmz>`gOE=$4~MnsA9w0qMz^gt zvv^z!_oi5wsp(vOo=K^=>RWS|lJAJ1o+QV>whm}v>RIObTrnMW=MT0o2%769Vz6BG z^t8e5W()set}$|<*Ely>jvaRkWj)*CV)@+H~33H z-WMgJZm+ zdx^i$7x#}h4O2=4%Ld%v0*E!UvI^+M9B1Revms^TO7ZiFB;j2&h(_{>>%t0Xx46gU zj3CO0d_!W4%v~mIb^@RnQBxgn)pP*s zBZGAgPmpw{$o|&r#LU89kqWZ-LhB7bX6--&8wZ532!;~+rTvV5>8k$NrVfBz@`0*V zLK1#;wZ7AT(gl=|25bogHV_($24vn+j}>$xUvz)Y-l!oKEzvTitc;2>&OBKnJ&1VR z@0&2|t`zGzqZ{`qEq{6x|F}K61weGY$R5U2+>NUn0mtO6lmilS3;Z@x09dPywNc8s zj@8mwYMWeKqhi(e6hdOI(CfaLY>Cp#Cd11ab6sV#p&qfIcA)tUikpLGkkN&eMAQYj zMCA7n;d~>}ZFRlO3#thIr#LY5BDYiPWP|ytLUy^i&?$EpQoD}fjnO(WUSF(qMQIPB zKmnnfe$37Ey`yfq$KbfBWB z1*5)JxRgjB{=GtYNIwqgNzX`>N7cX&r6E;g@&KN68z)w+GGV7bLFj=SUx zXY&$3mW5Zc0ZlI$mBz1Q@aG%0YV^#EZu;Wx#4$Qgd0cH8-`6dN8{~HSHB^>=?i%&|S2 zESHONxe~I=suD>LA0}t!AU0KVJBAAA^lQ)O`RGmX&cB**|7!m!x87cC|6Ty95!`X0 zVNNtX>lWP&h9gZA@_q;AC~B$%9`U=+_P-Rt(=C3?ZZ(41!^(w00ymxp-sFk~m2bfn z2(Y3j9zoxHj`+R&-~K%Jt_(BeAVmG7_qhocT~=nuzH(eRTw?6RkzVP4$aUX|#Q&|9 zuR<*Ty7Z%YJZZhd_|2v2d&p#j%2RJi*3a&ekMxo ztt7nhU`K|AAQk6*iwE*ddX=xO^6QQi$_7AeN;z9y=o_*}{zNZ64bGJbIE`IyX^k9- zZazQM8w$3LgdlTUB!ixY2Tr8fziwl-ZNvXwciQG$KeuX&N-ZSBZfM2((T60oN z?D)leUa;8B$((tf0!o;v_UUGO|BC2YNsEww8tC+tbs4wiR2M*ap&zapggV+Ce~g@y zWUP=M`NV2^SXNv|j6XzDVU=OEO~Bs~9Di)9_;~L>Y^0#kX(IPDK5bxE0^|L3SzU9{ z3b@kd=2iaXy6E{P+S81I=ol$4Z}Z!?zE0FB+~BM7dZ*d$dA^-^y0~b#hR3mUXkMYu z`Hm=wf4qndHzQ@+ecG%pHZR5~u=0sI*jqC_JB^YtkZ_|(!Kq1NmEIk?&GA3znq%|E z^=_QO_Kwu3GFGz1c5tm~R4}ZjYND>rry)z1mA_fg-p$91o0&5a@!@N`@nPhZQ$DOq zUGX-_azXDePDjUmg%!@ZDHH|o9l--Xtbp7czdS=NvBbQ8RfAhnc6bd{=-&}O7%x{j zMlOZL(V9=P56ggb$`pqKCsazhcMVGGR@1YNCa1m2e_kZ42ra3r(^X^H*7@?zEpA?% zzv47^;7b-pp7&o|UoTxu%ZhEP*jz6a08mJyOS0?|*6*V)#xVUhc(>r%+|u$j3J1c| z+#N_&$|(6|GKP6^m0E<*)+6P|A14p`N(=JMLO*IE0>LTOljhW9YBGw;5*Vo+Aye$+ zx_|MLW6IqEhV=UFln)Q@?%m|&HF_8_Sm{ji21$-ZVklX|C^JiDlHR{Vr}OD~Bv8SQU10ue%Rk z{Id4)Mh$hFUkTyT$?DkAM#tj7T^*g*@M^}sN=>9==jl^(iU1{YW9!!d$1dc&o-SBF zf4xjM!7Zb_b9{YKka1(1Z$KXTBxbJ&UxV1fX*mc%->%i2N3kKk#QSr z^+Mk9Y-SD#m5j9fpBZaLlhLno&YT>~2>VX@vT+Aikech+@|ZZ>78 zdb?CA$pj-YC1EF7m*N(zx6-QAQ?~7vzJf8{*CAosX$X^!JXGpbXXVV(QjZGQsBcB% zOuDFs32`KTrp>hYMPh+5WI9V+iKFCI4Zm?5L_biOJvaXs@Z#HwQn$>A`UGmLHalBP zmQDt7h-Ad)wXx|yusz63O^LWa1C)=PET4yFi?ugOO<^Sf0i1Ovm+86$ki55Tixd1o zTFLN9t)(FI^q&2=0!4WbFYJl}UCDU+&HubI76Hwqw(!4?){+#;(8?eEy7v=#@8HXX38s6U~^;9)+xm1*e=r^LJ2!^?Y z%>lSltk^*Tv-3R=CHdEA3{JbSR}pQSC)Y|j_AK8bQ`zdbKIU>Fxm{tvGBIFPrtmxO z!wMa%Cjkybo&$)>^E0Hxac6caIgOVH-=1taDB|6nY$2l1nlVpeBrRtqUtYjnjyLJM zl}KsR@zX?$aOD|dAGwsC|CsVy*GZ>}GFKokVFz_~&mmZ|f3SM_c|F1N+Iz)_ioIf3 z@I~8|p~>7}+d+>Y^vwqY9QBUNh|_a3xY2_%B)kz%CyR_hU}po&4pul@BRdX!qFWld zUPJ9DJ>41TWa)F)&}}^3cbng>SEbRBBTE)-4y?s1#ebi0565*R;(@>2)xVkdo()_| zPZjUSe)gY?+~y4(%`k!{|3d@gVq`IG&eE%M3DBQD_7J|!pv)!PIix6VZf4~S-?+p! zrfGJvbU+qAAjX?nKoZ7RfX{;N_4dXtgALwU;M|=HbhE-7TVxNP4Vd&KG&c|gfm&v= z>A?_njYk7J$)Xe4`)O|cyz7uvfitE9e3$=uN-3wTi6N71E&VB80(4G8Bh{cf_S=(R-Izi~B{!kb2`o375a8d9nY|(Nhpan3?lF znUynh(dwIP8&XKYi}RfjD(s})PT|AGZt661k*h3sos5Il>Rcyk#@f2&k5^qOFtT?% z<<7Bmio1*N``}~yRw80McA>993C>QAajgmR{hgv`I*yxDtQ>)8o8)diwno<)2d>3# zl$s8Q&TvRw8$+IeeY8+Ac%#d)U2rIFS&J9H`HN-k@h#sjZNotGQ8&}!%-;I~xs!i=<(MIozry1zCacA>xsRKY*3CY@O6{6#={`Hhh{@6sCApaF zo308;9V6>U)FU+MdYZcJds>FM%)mYVtNw-hY5Cyky!ae?BsWmBdwy_0Hh{>w6$W@~ z=yiOklGFLxAd*c#tP80>xBIME;_FGoKFJi#yXboMrx=&VD5*KjSnQ^yel|fOJ--z0 z4EfrOYz?~me#O7iWN^kMz=d8myuEQ;2I!%*w`O8S4f6?JFf2%7yFA;N{_B-}K@a zN=M_~iGvrrO3UkNg~>Ta={FN8fNq-d*7`Q2Wvru)Hc?0I6m0Rwc()=?#zHpqEX?A^o$t`NrmWBd znG?W{?unvdH%5@x%1t3|7Aip_mbYhJ5I-HU{&bHiLcxg^qy7!Q&m3A z!Sk#FOWPHqbfITq>Co$p_{Acyc&sO{wm)m{Qg1hd)bF`rwnW!fPr7vO$c5;i;X1?L zGnAM8l3$A7=r=LNAxNKIBY7A~b77ASn}gP9JG4tP8oL|YymaXakoye89}zTzmI`$&K5sYr}lM>uq}|>Js-<*_FRB ztE#mKTty+x*_1Q{BH7Wg_Q^(~q`=Fh5eG#knJt6K0(8uTypq~z*KnWtB=gj2pq>iP z*80ens{Z8OiawSTp7%fvtu@$(YGmfPL{gkXXiZF)%sRsfqQxtE{aBx_4;PQFxejbBM0d z>jdUcRFS1@Z}YU=#}sLJ`QBhmEcQipk3jO;5B^*HUDK=e@^nhRn0?a0bx$#7ZTzY) zDk4ZhMTg(b5$keTPY@Dk;Kx3rj+_X8&67QxkbFw=`*is|+fNzt54vXG(u&IUKiS1| zj3br`wh>%MDMR1J+ajMDy_G&Yr*4SPuv>dxgf65Vi z{-2`4_nSxTS%2E=kGnqvKL0VX`hNSFPt#82$k_3!Y4VgQi3t%lTAm(`X_Y4Y|Qru5K#8!PtHI7~YQQ(A0e#l)d(tZ`1+`QWJO zq;J*yWD{KdGJ;ThT=N9j27?~yn^j^*5=q+}pR;xW#ARpOYGD??Uy)KXndsixeg9xj zj1|7bL5%V6nyt;*@eqBI!N*;zxE3cS`jSpRQ@Y4nZy1>auD*LujVl}}E6yr##WuHk ze_7feEGj%XA*b=W>{{h53A~7*v#nVKc3jHt+10w<{%v_%m$ij{qAdK)D}#YBH4yhl zu3mw^Yl&}vt2q|5w}WwGFcDRG(e>G z6_&kbY5m4F_1~3C%RN+mqCwy8AmiD6RUZ9 z?bqvyO|?8kps`2!%hDc+7k1ruf*1)VF@ajJ!ukvhXrAv|U;O8GNEB zK4f>v&h+%H}U=dDVRBHW7G z(s5Jm{>pV32jU#Su0tvp5(iaeajU8)CFQT^95pbDUUrv_lPmuidG%#bk%EB*Hkm1l zbOSdNcR}jjp>J-rOH~lYFZF7SQeTs<6Jf^>-s^n+DFO%au4y& z7e|!`kBR>f!~B>fN6`wR>l+a?=3x(3e18s_RPY5@QV#KC9`sD|IC~}FF%fI5Jdzu$ zig^@L!7>brD%jS7oNCRKiCiD$!`UYoK91K7}qAjZqDasv(XA;Y1=|(G#YR#f~`0biq7Sb zX&`@V6{5v}bvRy(a#3FWof)45T(yj^o+tscx9sh|zD&&-h~zeK)vp`*!1#D{XK{S( z)-$2OcIIrRVV4hc2YwI6__X+`lsI-~7jn;NV0Vi0l^!nbDQHd$pX2PI>q}u7`)4kz zexa#hbb_NqUrLq0{4Cq|b> zyOD3-0`2r0a)ouWE0>}xY{!zmUUBj&ll8Tb|C1$ex=#IzB_H7x+NjUoTCe{7PTE*B zZ-juc#6a#>qrg~FK|J4~O!uRO9OU+{azmNkNt`BVXx60;%yR*GFVt7iSvs7y8!>^h z3GzXgESP8Tk5177Nv18WJ$-XjSJ_@@4s_jf%LJ%#LJEMPB%7Q^q6@wz7K}e;m8?G; z%NG-Cl%zzKf1=1V(KVH-s%L5D2}=>-)bRku$i-}bwkmHU`k$hbEl@}nTe=gF zdIQkG!m3X^CMM5UFK#7XZ!GIvd)H&z!$S!HOYyhnI$&3&2!0zB`}+-=&8YhErwKLJ z?s`5kxzH7M%hxmkHTrGAU~{TJx3yRM9n?tC{Fk@nzRx?yV*8J2FWzuTk}*C#x#u(+ z=u}DDX-pO=c}qRMoWO6DvV1H>S%^KuV)Q0GSL**Pd)===9Og%@yE#%r2KSu58Fs64 zWHy1#`e@)mCV9&k*OcQrKM1sQv@J>I{3{cEgV)<#TDp}+msth#B~G7%Xmq5~p5$K* z*!y+EQPv~AAkey@uGNo1{*zNzh@46OR-MuR@`eNZI0A9e88YPb?ZSyA6)98#I@ z$+Ks+;DILc3~%BNTt!sx`;KmMMd}4uq)(p+Dv{8lN3rd7rWK&^ljv5r29ryB^Q0gR zvDYLgaQ+RgeZ$qjhpj3%R3isQ6N0YTuBIJEH7WzOY{(s_xw=f~xz=KMvqrvr%MvcM z=1QLH;ZciV*^2`XQWmc4O~48=;!lw2I36Wb1$4_mwJ&#>D4A!@WrnVwwl{d}8h7An ziZu}G`xtAzm5S2h3n$AyKnJN**J{yIxOWgcgU1gjAO9K3+T8)z(-K>rdmdXHjask_ zaQ2lDwC-N7TRzt0q@L{tz2hS;)oV*{d37ZO+3T1QPmU{$<$j2KM7+PHFffuEELZXE z<9LpzKp6j3@1%&`;9V^z3PdqHHV$V}V%GMXuygH61JH z0Am8l6_*azz;e_;As6DQ=I*8%0ob9#H)IdJv=KX+Gy4t*HzWV0B;M*qB63`0ba$A& z@7_npfC;Wz<5fSi}(oo!DjZPeQr!1>&_KZo|K-%J$QEn zJFs3ZeqQLU_U(GQXcn7?1YR0G>?DVe7M&ju(QqlfrFFn+)FkS(2R9!oN?8zE#-%1oVu5)}o9bI{*h-mY!z2-bg|;Lq z!NH5cTd4X?O0pPyX{!v1-FhBfr#bpk9(sPd$}l3w0yS=o{!^rBr5iuy?gkKbCGLyG z&Y&6}T3JO)x>w*QoE=1YmL}G#=MxqunZ*_uXg1qV4PoiOt|m^oP~R&4a$c~?tD(5- zF2l^F$E6^Y$fI2UPHCtS>j!ZBxGCS){W3|V`%R@kDzyJGNFgg)cs?%AstX_WHGn*K zTJ^XgCe|K)S6JhZ;Q`A6MI~nKMuPX$7vAni>kP-~K{YMDxF-s-3BB(yxwqOl zB%2jBw{MONdQF#aEbTj}lwaICtEbWtl*5rgJJlY5pFHp5Xsi9Tt&x; zV8}Qpv{kh73a9jUJOQF2o3uFY^>N)D$#OX?UHML@yYc`|7BY0hAuE30 zM5W7c$`bQfu!Yi4?F`6&Q7ycpMZj|QDy06}T{TZnFZ=lbA(KOc$q>Kg-SjEZduEr+ zNA1uy%L`LpE{Z|f#9vS>5eaer_H-%0TEtWnC}R=3sumk4t?fUrz3{2de~C3F%Uu~S z9((BnSbyA9gl;EsrSKPZ-|JEZ*`MoDgsW`itEgqKD%WsI@>X$nv?LiB{fTz>sq#c# zrFF`Ql3QFglA4~&g=SOb^380CvqGXam(R6f{Xva-9Om~2fP0zMs4sOV_G(u320*y(n5ffzmy2z z!-0B-B3|W%+lquSjbRC&@{z;A3k#vbdFg65l6VB9#LWvdJi18b=UP1Qo!%=kca|1e^}c<=&%0Y>M6Su}z8>!gN=rJNcu>Qgtl)Xo5UoI%j(EbL-wAX`3$c znoc>@i7nfn5TP`JC9mX$&2dV?UO7E2@glx=QCO6sfB*UE>Q820tMp&#FqSqhD?>Qo zewmppWT`m>wO%AqXE62-)ruD3?i%TX7C2yl8moxuNa)0*u|j!X04$cd2InI`q(lYf zyrIfC%=6UB`Ol?B*b(~^2KNN6dS{-KF5}dEpdCP8xw7@YuH45maa^tp8dtq<##STr zLEUU(ziK2y*x6rnH}wQLA_nYmI~WPBzLNaW8d0i$j5x?$)d}^VH{6SUxRer|^{VZd z>J7%eJ|nRGe9izLgJZ_R#2u`C==Xa5+0QlK4^rRv|NQ$L`iR(`fljja{KKow3eM1n z>7l}|wEG-A%*tcNlR{IdoDh8J(4JMO3s~m+T9<$BSmbqE8H|*R!NYk^(3vkb0X+4T>+=^|Q!qrjU?-Vx{?nx?C}IW8f3qF%!vvVE9x0NbmXKV&%SPKQrg&X=#h8K)-U zZ%w#r{Rf?QjR{DP&SUB_sDI;4+LJPC^iU~k(HJM)Nfj65sSR78Y+?#F?urP7@ zr;&IyN#tnpW+yPnkm2M=M&4<-7W?}qV1hk_m_4FMS4JGMc>O<{fAnFrm~^<2r%Hk_ zVbOYoyCiYIz$|9oex5dWa+(-Mjg3z&&MYGT`gwG2gupLbkQ3spo*wwXN87?oyJe6r zZ>pt_w)L==!Uk7$1-2SBgLjkb_Z!LI>Va~wxtepXW(E8ZXE@hxxK?~6{wYo!y+lc> zBsP?q@0!>}tPXZK(1xX;bXvlwp#6O)>h(W{XIqxLn`w+1tu;K(gUh(lTo>d?TswEH ziNR=uf!@+po5U9h|4Gt0PX(;78A}E|yI6mtP{RwZd!ly$Q(v0jp!A8t#2>iD8O|pD z_~Gk^@K=kOL%(@$ZADQ%_C|I7nnbgWnjlb0I*r+9n;QPxhe&ZnuJy|Uw%wfL;K}Ja z0spoX<4UjempR*yRnTn4Q8qinx8FS-2YTJExO7qK>2MCl#=yW}sqM28{PIT0hG{s( zEgC^8!VSIp1u`ted*?SCbStCso^rZq<&Ar2!MGVzuAAD|p_!N&k0JEPPy)xDJ>sQ} zL^*8-hm4(Phf@nfQsxiec(O0~{@2OAZBQ+cLyOO08&!@$)EV;nSnRg*@ca6OEOz>KFj;Mx--*NA6uAFu@ zd%6I9A0Fcj|2yTR!M2I}=!iP!ogMso8+igZp3C466Cba6;hn1VnCh`JxHIK9-*k8g z0D|A==dZx8mRm)|4|H6m3J=47?}KQ@Y~pLuKfGZyD6Kxtua$>_`Jr!mYV{=T_V_oV zBr+b&aWZABCPnP_+UuD!YX{BeFDxiX z3pWs={DKZvc3MI)Up~B6YmL)(J(Hm5DICs!NjuyrBS_2^?EmO(%KmPREv~}7w?*w9 z=8geIVmYjsvOVo}IK9)&Huz+J-W47SNlDm3UBD~)Id*ZfqkOH&)sf1?)=$UE;{-~f zhwn(klP0e60*>DYJ$>(n3=s|Fi&Zu72WdJ^-X=SANTl!YV%u|VPd`SxOyqXG9uH!+S?@;JTPK?$BUk|Oh?_J%|mTI>J zf1y%&M~k%H26-HbXUr#j*4xK1v3Emh(m4=Wz6d*{xVOearZp(%XanEk)<4NgWY5Hc zTd*d-?wuR@rC!DywVikwIf|SvOK&>lX*vM3io(v31Knb)A=fj?qE-f@oJVu#SJ(~} zQ4&gG&~NCY^S>WX`acR<)}hdq|Mn{Xd7PSMLA^U)UX(5*-nyYA=GuE1eK@wB&Bqd{ z^tq%)*;aEYqJmg-Z6hoY!R$^kniRXzGJ$I8{i2CaWDet5D@Yuuf~|P2W%et&I|gmP zltHr1$!vB-zRC4%(NQ+5;rHpO7pyypQ0Hd%1L_g|_cs!irq*hfYBP~*hCIDc(yPlz z3bPoRPj%2A4=1+Ie<2y1-48rqE*qUad|m(2+@>Mq)+Blsmnc;wAA#TL3_kEXM1tY3 zfxT}6)jV@Ns#Zgm_a~cAj_h_Aiq`a8`&t^+(9?64EI*N#;v&-J#z|gJTW^0Il2KOD z8i|)s@?Vo$pE%`qvU2qqWb>Vs^M)Jn-{(ogjAlTOeI-^5zbKB0SbW=bIP z^=qoxMVO%i{6d3jZ|W_y6Be1X`qd|Ln`}2NqjrH8fyz}WK{O)Lp?uiTug)VKZ?Mg%WDe_Uon7`{p#YS`J zE+b7~dWuYmjIe_J=C*y#e6i8@QMz~U0&2f{e4DXxeS&gw#D9)?eP>P-oxRH5zU;g} z)!5(3-(+Rv;q8jF&Tt)}?pL|@Re#=Zg#;Ly?)8DC@pHCCp)j!cJ_MP0?rVzct~fD2 zqh;Bov;p6LP}nL8Ij2^>dTsY@xOGN^b$!SDrpTN6-ejdhv>ZK`&*vvUjaZADNKWCU z|Bq8=#a2-C4hh^TB9(<|zHcbVp?Q{D(l2s%rY?)R>}llQ7n}JLt(S)^@SK!x5VJ;~;{@0Nw+1UTD_P#u<$usRY)~`}) z#j%t{S<)6OZ9!v-vO~sNrB;PnMNotY8Kuf*WJ!R8U|kS_q?Hk(0;y1;$`YfpLxMmO zElWUTktKmdNLZ2(l8`+)Z_t^6nNH_?*LBXhzH?pXukeufeedVqfA@1g&wC)fGG)W0 zCa*2l4;)196|*PveyY?+6}+?zQg+!S%!V)C;oMapA&5S@=jWk?^H{0WtUkQOQj~>S z#hZDjI&u@)xz-z1$hPJAQxTE(M~@IW?CMwS81m=NWtcGTNYioar*g5$gH@1hx0b+r zZlj9+SnisT5Tue23Z6en_8f%wSxo{O6b*hF5w%GZziBwYJK`)yx6pCA2RU7E%g1_G z|B8S0UtttX^KWk(tTp~#UsNV13h(n{;qb=PR!I+3*MdGfHCHy~YY5d*v_I_&C+~G& z`dgaTnP53Z%888LB3^eUZ_<~PZ3$1^5sIGAbwj8HE!wf`E&ibihvWBqZ0~9m6{6!G zAA;f*b|7cR0(4;I{D6INv6NUlR`NnJ*qF{^tijvqC&->xN`mvqgosLy?LO`kztqkv zq+E)xh%gLJlCHdsI;%-Vwmc#}Fg%8yRKJZhd>?~&DB-(CYaABb?Wu9Ytr9__FE@7| zfzXA&z>UNmuTGjpHHO%y6j0GKcxS|X^MhV8?!!rMc|7!Ta8$&w?*spuAo=(Cu1!Gc zqG_wBK=GFjQXFn%zT5g1^K61vHO->8tu|q-&f{!UbS8al;pO8XsuK=C72si|-G!Fi z;kx{};!@rv%fbwIls)iG3H;>|1fB#l0o)Tx85m;K;OXUzxX?(9X&iARSkpmg+t9 zKEDkgku{~ncHHbYPd_f_2VJ+bmZwx;KdbaUb1WV=asB{&yT?mPVa+6?il6@h=_b_P zV{<(Hm&clOV701Nrthrj*|7Idw$C-8PPLM2Zu+0Gh%bF3vSvmg3%c1G5*@SpBL6X8 z>pZDb{@`|wwgH;T%^Ktr*YMOyVy$Bd!Rcm92wTDCNp!vVD|d5B_V_ApBS#c`=KQ)S zIIf&1?IgNWolm7AQdI*c!&OPcu%zY;r1r7KG>g@2W%q^SRvI5dIVF%L-VZ5GDqvQ6 zx|(t#$X2fRyKE0F5OgMtxrVqr(}riblwoQsRt;&A^v{;@p6^;9WSEtF#eCGty;x=$ zW!p-R$)FYw2K1+DQC>3=c?0rkyT2%adsgjx6$2!$rx4Bv}qeh(k z3D$1~NQqkJKQO-}BgTaf!>2Fq;*MwxlE;pZeT&69wCtAe9;WOeFEDdY>K&=5NKAI6 zQwo@jD#$uoNV{nORM|Rslq?0G*slThA66GH74_V~8MsXx*=_UY10GIYxDVk%ueY#< ztQQ0;^6~szRGW6IuDou$Li^A)wyjsuAyc%P2Y`rruz0XGHdi7#%QI_dVF;jGfC6a zZr=kDAl`PP1BO?K_8zVNOIzZRZ#hTF4{r^f{#Rmw?Dx-(%boy?e1GZQYW_R$rxkUl zTV9<4Sn=)Bc%(!Lf;ajb=IfPxku#TJ)w?3O^kJNpt9OkSHkk?EhuGMvJ%-CpuQa5N zD3#AfBloup=T$2}Clm*l-R4(01YZ|uW1Ztc$cXit7YS(1RpN?TO+M=@=3jX{&o#Li z88V@BSTdWRB;j7uj}vyBEJqOK9e9ZU^i{JM6>|_C8TeiF08JwWmg>Q`my-SG+x!ZZ zB!`THn%<-U91&S+?Bo-*+!_ddUO&znrLu$2nj6rBXFnmQ@)hmxcoiw8kA8r{s(uD? zpFCa{O|dp5yWp-u70K;2BU};ta6D%zr)JUbxyoo@ROgVx=KVMA97eQ9bosaMr8RjD z_9yDBVK4y99lWm&C(`5TkK(|1da?fP$4`W3!cu(^wG#K(Q9FkYH;0%;$t>~Q za~4-l9cQW1qTima9_~uiHTe810q^zEHi!#|c4YS>q12(wo=aFRbxu6A;KE81``kms6Lupzx8e?8B$ zKKwhyd1Ny(kcn_~HFT&;dGk8Yblc`sZB9WCc1l}89@VgjiX1Y%hjep;ZO#H-hi~{a zhG<|3Mzv^hdktelI@l`3kL_4uo1A$3oJxgkYfkZVACuLd5xG>NIVHAP<3I8NFL*ns zUdbl*CHw_rxFp+mX~!BKJCQAzhD=pA0Sk`vKiI^eyP zfx>OE8}izt%Zy-tLh}B!MC8812O5^V-_UVG(%BH2*%$mJ%J6`Q66Wy+cdf&V?v6rZ zxLL%cmvxd0z8t(99K@GPx_$`eUF#)eW9wIuT(^|WqW0$}Ny`iynU7PZVR6Ie$mxi6 z_|I}m>CadCXRnD#b;*4%d)jlbavi?c#VCjFj|0r?QcazYzqZ}W0(dgKV>*AMuP#1p zy^HVN*n=a=x=Lc|Q{KdNHh8+JzawK!35vP8rqTPQ9oK%fbcT1M%rq0}%(@y0Mt@$2 zo4SLZ^pE3;I30xDhWdI;v{R^T0Ihr69!|?3Wn_Xe_H0btcp8ntVZ$G)*e}bt8DblA zTflK$s_}ZCz#`ewYUhZ=?UE<8^G^e;;iVB>cc3S^cag#z2xAt=E0H-c@IXh3i>OX{ zMc^iwMxVk`LWUn)fR6mCs8WtcC^8InxFneZlELY^x!we8pjUj}n$)r$+4u0)g;b6wQu;ImDvIM|YQQNUTXZzRDsLrxi+Lb@R67NSHbU4~ zLD3+p<&pCFje)i;ip~tCLH3nbvLTks<_AXE@v9^w>j;Fsz5W^Psos#nr12L-om9$8 z+pB4tqG2O?eQX~muC*?kHadlFf(HFtTh}LCAi)T{*G0QWIwNTo4_l6 zgN$g6dG-QhZrBL@27N!69AM0@S->OHL(QdmQHC>9!qMMK45JO)H3cC^8OtJO?O;=E z%zJ}5euj|7VVZk~zYjzd>^vu~<78Dn^G)Qce)4N%nq1HsM&;$F8|0Z6si;(22mjftkdZocuZzsfI?> zlahS>Fgnt@W@H&ee9*rHB4X`FD$CVcAZTPR3mTUNSovkQC~hjU=lgI~b@|_MU90Ht zxGwB#rR`B7=TF{i#Mx_1M;(2j6Wwl-mKiQS3736+w-q~-C|&~JW*U78b?o)f7_nn` zs{gxEDb;*5hn2x0TPYP<$8hpfgBdDYQ0;w7c;Y=1n$^N}u$@^-p}qkfD-950aSiVH z0?c9U*u;Sum~zc|(c`kA^ca7rkK-x$_PNGlSHnojfGv%49f}k7D`tYV`$uHiN0G`E zq^qBWXqY}G;EkhZrHqJJblk15)LkrX?f1z8H2oJvu{~Rmvu%}1ZpgkGRr}>^#0~%i zyU#V^Kl(fRJ1%}wGjri83uxqc^!0KqtiPjZ+mMn-r_h2PW#4yy*E^1@t*msLs_v|@ z+w1c@Mc!mqQ~1n$)N)-@xE>69&ogFYYNmZx>M-}|q`Ls$m<#b*(`~6Ze7&&Zts!h4FE8E&G z*&{SrC`XTH+~Ubjhj_jtUBkV0Bbd~yPz3X8&&$1dsP-D$N!Lng7;(t zd?d}a@%lMOm2ZmUt=llo?G~*m<7{uAgxOZZr@C(nrL9-MF<-~$pKWJo_zIe$!7chk z0ijl3Wvk^DxrWw>1_gusTM|tJ)nYWI(85)ReG-jx(O)|eImdbQH_T|AthWiY+4f0E zSAEKi{+sG@Y1jbuQK#duFEwP>VGCfc;oZ-yP;zUywDvL6^Y-tH< zkK*nCy-8Y_rHT^~g6)zKaaX|Ff1tPX@&d)#5%lApR+=^;;*v^;4})u^c<1nzj&?*S z%LErQalx{!Fo%^jY;bq;E8{*b!(YYkO=sK~7&RkoMc=a`9Z<7X(U`DrQ2VE6ub$?2 z;!o~U?ELr8^3zsrbFzV_fM;*umC{SQYCU4=3>2cF)PYNRp`Z(a#bK|mG9D`sw{^3h z+cv*iZGUZSmlfg@A9LkQWQYlr^3;+Zkk28hsGQhS1sJBUHJ8MkM6n&S7#$kF>D}& zpki#OtWanSCL(_S3)q4IKv$!6x5NlGZM((D*_}2i-FoQhFC#GFEulTw?$0FGXZZBt zTeM9HWo&fhLLYMaBMq}{^`RofP#ChMZ%wtn=)_pl@4~W~chv>flQqtlb?scyL&eqA z5hJ$yo}r`aLpnl{lVaCcoH&It#@NSIURcOpZCe1XjH3;rPGG128{$20^huO8rW>qC z2;NX$x`F;@R&X3|D9-PyebO~q{Y$=9rwYbx=&spb*u)z~4y`BQyD|=Ob8-|JSc3Y@$o%w=!S7|G8(g9c zi_vo#)0JP#87;#R^yjP7+v*es#EcNWr)(Lb()U&-viBmCq*<66xV~BR%O9Yp6V~wX zdhccMX{y@U@EOD7vf(KPa4Cb?%L367eDokuiST^n651#?0cWxlTA2R$9CxQal0$A?O?ec86Uy!eGCa}-H>LqI%-!9+a17R^uv_-j8jB~{~W z6b}Q#u@dE;NH#u#-;rsVCtHVm4{)G6m(rc~ky@YVo4@_!F&mBaQcuIlBu-JSO6nXw z|FnkQ!XAemn#n}Pz`PmjJU^VxTFmBs=0=v`9$)Z8jo_bP4-T^n*QjWKFkd1IdUSGT zxcolTbqcG$sg$+Za~oI+(-b2qdzX>2PLPpT>A7`tN9~8AU@tTsjcZKdsG!j-9`OG(jjL{j`t zufZI!W=#4I%mdZ-L$L;oTh}Pr(I-JNg1fY!rxO?hK0CIU>KA$`;XxQN;gfa8|1_H8 zmtk$Fmfut9>9kD9{*mNwhmc*Zt+>_q^;5MNLsyu|{R@FA6x;(AG6979IdR~Q@p1mP z)`{0^2}FI6wtFC)@z+D#AEbCZ-XH5Y=>)dxtckrCM*Thsdd?{ElyH^7N~n5LVRCr-C<#wFzO3syyJJIc7p%SZeD~ zP@{k>QD4ZTr<%dcEMdw$jjG9JR->I!@t5%0dIJ^@2U7G$+ za7KMaN{7D(L}b13d5SS$B+@@aHP#f&HEj)5P2HtL@K+-x*CUvZDi84@$kF_24{R*6 z(*d9XV1m;mi#pj`jE7Th90;DU;U^igyYmjwT!1o6q zTaP03*_z!`ZZ*DhV9T;n`4hE%1FfJccE8V5E?RgU&Y%=%m@gDr3-?s(*}s)U_~mo@ z%pFD-x)R|uaYYJmwyBi%r^~%Pbn)4R*9^a9X&be4*@ejzr8+XZ*36D^@g)E3fC^XhiD8u+%v_DKlZWVt^l` zX+JqY-3W6JI~7k#jcAnAq;Sh`gh-~^E!*aSC|m#HW|Bm{;evu_V9pJ4XkMwVWsV-y zxnVTQ8U~!P@wb}sz`EbDz1zv0D`0T1?V(dTANDAw{RR~!!uK}RbFa+6v*jw)@S}+% zE)h&fXsUfE4ts^xXkDf^r)#)!YdirzWy!d(uWXLyQK)A_p<%R4-nha9EG8)I)X5#h zxf`|Ut_NJIY-zc8!JIr`4GBq!&l;B2*2-Mt5F&d8JsOARU8rFk{k;n8x5>Z(mVaKk zjDLn_;CB7lgZoKmzryfuB<#v1XFC`a-Bzx=$l)ve>(Ln1OqxMdze(@*@^dv6CC)zD zThINmOm9>6Y^EHmuXl@|e=L#v=EY>sVa4=O_>e0Dd@jMOAoldbr9nLU_<9aa2J}9c z+ZU(IKNkpmw)-fWR8!NM-;2(}bzcI8GR6U7k}ALKb$m!;EQ@KpR`?CJBGpUXXh_s7ci4wV+KmJ?90!~ zgL(ihu1J%#f;&sA#fWbUE>50os6SI6HFCe`rgZ*`J*694e}}*Ka=II%0s|}J*nXhF zki&7Ip$x%c1d(YAO3Yol0-Lon;r*L=!s&YnU5mUv)79Q?RdD7#>Awo~SHCZ%4?;q! zx{^FsoIoAyOiJ2Nd?-824h0%Ky0jv;ZB^gi??bE0%KF_G1$@sP0zt&zgLeF@2!Hli z-PxX)(C5Jy{#V-Q?j8JzlUaT{E+>twI1zDoNw-+D)nSL*NiQlZkMS3^>etD}#;l&Z zjBUT8H-Bg5x)Yx#IRS5(ozD;4viN-F+Ut$6pc9+!xo=`YU$;s(TfNNbKC^gd4Cv5P z#0pUKkNn0ni!<|y`Sm4sCCL6@=SNBSyRY{_7}f5`E1Fjlzp+~b`u>6!Kj0P5kh?w+ zTZ61J&X_)p+y8nsf7>e6rboxBdofYpInne4ivDuWWthtb+k}=@1%XT;%$m(lr6wbC@AW(QpS7^)yk2qO)A$d z^mryzvZodV`0G*>8@hYPWmWC%Yl^j?tgm*YzjQBoU6eLDj$>P_f4Y%JS0|Je@moX{ z?F&Q;M{uyY&OgL%--4CaU^5A`$RyOe^h4lwGi3MaUGN)#r7UCUS3cDd<+ZreQ9parLv`e@IP;JB6&d;(cxGX{=?itf_h#mU*bzH#$hyYswk$9MJ6lBO+B#XNfVi&rD z5??;0%=cRs;dHkslTQpoGUcQS+ZqQWn3G^1<>K`DK{{p)qIKFKyw4{Xzh>2>?x6@z zl}8qf39yU+xIWworf&<&;wXd^5`IkYzFMDGAZJd!-nq?4fXRVgfs1UopRB z3Vhrl%r4EMZ1P7h=Hb_!;D{gnzbrfHS?%kj>x!d9kGp%z;9)M(5kvKMkzSCx<%=!s$ivDUR$KH7s!dkHld3$i2-vp{jDm^kgw5m%5D!Imd3pDg0E2;0ndUxeb zf$-=9-ZoCs{_=VrMGztjl|kS(*x0QbiNgcFVuk6T3~fGXnE7i^!!Hi~aQWQ>2Aaa0 zLX8NK9p9FuoQXwpg*^iUme#8ZYr;nVgw;{P#{I)z7;NW{|2s}DP=YWb@E(lgt+oE6 zc99m1me#pG=4CL~yXdB#yZ!Fq=EL(rz_G$F-O0yXKF(of7VZF8^&1vwT&CMOBPMu` zh}NQXg#0Ke^A5@9(_CBBNtu)I#ZDsZvwo`UOLu1o=YvVsu=dj2^&HU^svMVD_0Tly zjz(Ff!!8oQJPI#&KW{^!ZqYDN9^VPJQJ<7J#`#^{Yu)b%=ls3<*)?s+3q6?A+t(*ptrk z7Qs>PIp+b}YTlrp;*FTdPoRW#T|%e}Z*Bo1(H}&DD@I3c=;I(-xeno-bq;M0ScM-r z;#B)(WSnDvVYTX>62ql39|cLuT#IopawzKi{-(Ij_4odX>hCgPaRKG<9!ni$)VvIm zZ{F_j<$mP2vMESfVCMR^*n1&6z#ni2uBnz~ex4q=w&%Wf(u|lF)525d$O5YRTbaBf zo9c_RDAV(wAAswV)peA4*%sCY%l1^+d^4rBfuf&mN6_!CB#>nEAW8NuCQ^HSJ!%%6^SQcZ8wi8QJ@u>~W8AF-6Zi^b4{4(^nBpzxsiUCgwe?B8ETwbrh6VPv zsfHD4kdn1ijQvA5{ee9xV2k3JeD z?hTe2_mXIT2Hz$dfA2SHYn z1#xdRJJu(a_r#ej6VM4t=&H~j{(q$f8ad84UCzqzruQ0)SgS!~!6ao5%N9f>y8L48 zd^*wx*m;^pPu_suO)>+8_H-h$3q6->Nv8aZxvM@7pvIzG%#GeCkTQJe$C!>^Dpba8 z)D|-*$IP*QAoOmdctp$j{$`>^oBTtG8jHEd!_Ks1&%KtvaiDVBj`2Op*S2IF2qfM- zYp;FHa^UYY$NdLt*QdTVV_=}!ykzspJFr*cG+r_G?jf_7i=F#RR7XMea zY#gXRY^Z$Q&b8pL61dPSLCboIc>&j)+rdQOz8F;lB@eMx~ emAQG3VZ~*QLKL(Lcg?7|dw2VNRr%$KAO07cQX7o` literal 0 HcmV?d00001 diff --git a/scripts/logger.sh b/scripts/logger.sh new file mode 100755 index 0000000..d60b902 --- /dev/null +++ b/scripts/logger.sh @@ -0,0 +1,41 @@ +#!/bin/bash +BENCHMARK_LOG_FILE="$1" +if [[ -z $BENCHMARK_LOG_FILE ]]; then + BENCHMARK_LOG_FILE=/tmp/benchmarker.log +fi + +red=$(tput setaf 1) +green=$(tput setaf 2) +gold=$(tput setaf 3) +blue=$(tput setaf 4) +magenta=$(tput setaf 5) +cyan=$(tput setaf 6) +default=$(tput sgr0) +bold=$(tput bold) + +log-error() { + if [[ -z $2 ]]; then + echo -e "${red}${bold}ERROR:${default}${red} $1${default}" + else + echo -e "${red}${bold}ERROR:${default}${red} $1${default}" + echo -e "${red}${bold}ERROR:${default}${red} $1${default}" >> "$BENCHMARK_LOG_FILE" + fi +} + +log-warn() { + if [[ -z $2 ]]; then + echo -e "${gold}${bold}WARN:${default}${gold} $1${default}" + else + echo -e "${gold}${bold}WARN:${default}${gold} $1${default}" + echo -e "${gold}${bold}WARN:${default}${gold} $1${default}" >> "$BENCHMARK_LOG_FILE" + fi +} + +log-info() { + if [[ -z $2 ]]; then + echo -e "${cyan}${bold}INFO:${default}${cyan} $1${default}" + else + echo -e "${cyan}${bold}INFO:${default}${cyan} $1${default}" + echo -e "${cyan}${bold}INFO:${default}${cyan} $1${default}" >> "$BENCHMARK_LOG_FILE" + fi +} diff --git a/scripts/randomly-generate-high-velocity-data.sh b/scripts/randomly-generate-high-velocity-data.sh new file mode 100755 index 0000000..b7ce0f7 --- /dev/null +++ b/scripts/randomly-generate-high-velocity-data.sh @@ -0,0 +1,180 @@ +#!/bin/bash +if [[ $(basename "$(pwd)") == scripts ]]; then + source logger.sh +else + source scripts/logger.sh +fi + +trap 'echo Stopping...; exit' SIGINT + +usage() { + cat << EOF +${blue}${bold}randomly-generate-high-velocity-data${default}: A script to randomly generate high-velocity data for some DynamoDB table with random attributes and values for benchmarking purposes. + +${gold}${bold}USAGE:${default} + randomly-generate-high-velocity-data [OPTIONS] [ARGS]... + + ${green}-h, --help${default} Show this usage screen + +${gold}${bold}ARGS:${default} + ${green}-a, --attributes ${magenta}${default} The number of attributes to populate each item in the table with + This defaults to 5 + + ${green}-i, --items ${magenta}${default} The number of items to populate the table with + ${bold}Note:${default} Items are populated 25 at a time, so whatever number you provide will be rounded to the nearest multiple of 25 + + ${green}-t, --table ${magenta}${default} The name of the DynamoDB table to populate + This defaults to $USER-high-velocity-table +EOF +} + +ensure-required-variables-are-set() { + declare required_variables=(AWS_PROFILE AWS_REGION ITEMS) + + for variable in "${required_variables[@]}"; do + if [[ -z "${!variable}" ]]; then + log-error "A required variable environment is not initialized: $variable" + exit 1 + fi + done +} + +parse-arguments() { + declare parsed_args + parsed_args=$(getopt -a -n randomly-generate-high-velocity-data -o :a:hi:t: --long attributes:,help,items:,table: -- "$@") + declare valid_arguments=$? + + if [[ $valid_arguments != 0 ]]; then + log-error "Invalid arguments passed. See usage below." + usage + exit 1 + fi + + eval set -- "$parsed_args" + while :; do + case "$1" in + "-a" | "--attributes") + ATTRIBUTES="$2" + shift 2 + ;; + "-h" | "--help") + usage + exit + ;; + "-i" | "--items") + ITEMS="$2" + shift 2 + ;; + "-t" | "--table") + TABLE_NAME="$2" + shift 2 + ;; + --) + shift + break + ;; + *) + log-error "An invalid option was passed, but somehow getopt didn't catch it: $1. Displaying usage and exiting..." + usage + exit 1 + ;; + esac + done + + if [[ -z $TABLE_NAME ]]; then + TABLE_NAME="$USER-high-velocity-table" + fi + + if [[ -z $ATTRIBUTES ]]; then + ATTRIBUTES=5 + fi + + ensure-required-variables-are-set + + if [[ $ATTRIBUTES -lt 1 ]]; then + log-error "ATTRIBUTES must be a value of at least 1 so that attributes can be added to the table." + exit 1 + fi + + if ! (aws sts get-caller-identity > /dev/null 2>&1); then + log-error "You must be logged into the AWS CLI in order to use this script. Please log into the AWS CLI first and then try again." + exit 1 + fi +} + +show-properties() { + log-info "Using the following settings to randomly populate the DynamoDB benchmarking table:" + cat <<-EOF + ${cyan} + ATTRIBUTES=$ATTRIBUTES + TABLE_NAME=$TABLE_NAME + ${default} + EOF +} + +generate-attribute-value() { + declare current_val=$1 + case "$((current_val % 2))" in + "1") + echo '"'"$current_val"'": {"N": "'"$(seq 0 .01 32 | shuf | head -1)"'"}' + ;; + *) + echo '"'"$current_val"'": {"S": "'"$(base64 /dev/urandom | awk '{print(0==NR%100)?"":$1}' | sed 's/[^[:alpha:]]/ /g' | head -1)"'"}' + ;; + esac +} + +generate-put-request() { + declare attribute_values + attribute_values=$(generate-attribute-value 0) + + for j in $(seq 1 $((ATTRIBUTES-1))); do + attribute_values="$attribute_values, $(generate-attribute-value "$j")" + done + + + cat <<-EOF + { + "PutRequest": { + "Item": { + "id": {"S": "$(cat /proc/sys/kernel/random/uuid)"}, + $attribute_values + } + } + } + EOF +} + +generate-batch-json() { + declare batch_request='{ "'"$TABLE_NAME"'": [' + batch_request="$batch_request $(generate-put-request)" + + for i in $(seq 0 23); do + batch_request="$batch_request, $(generate-put-request)" + done + + batch_request="$batch_request ]}" + + echo "$batch_request" +} + +if ! (command -v aws > /dev/null 2>&1); then + log-error "The AWS CLI must be installed first. Install the CLI first and then try again." + exit 1 +fi + +parse-arguments "$@" +show-properties + +declare -i i=0 +declare -i items_written=0 +while [[ $items_written -lt $ITEMS ]]; do + log-info "Writing 25 entries to DynamoDB..." + aws dynamodb batch-write-item --request-items "$(generate-batch-json)" + log-info 'Entries Written!' + ((i++)) + ((items_written+=25)) + log-info "Total entries written: $items_written" + log-info "Sleeping for 2 seconds to avoid the partition throughput limits..." + sleep 2 +done diff --git a/scripts/ui_utils.sh b/scripts/ui_utils.sh new file mode 100755 index 0000000..03b9123 --- /dev/null +++ b/scripts/ui_utils.sh @@ -0,0 +1,36 @@ +#!/bin/bash +TERMINAL_HEIGHT=$(tput lines) +BOX_HEIGHT=$(printf "%.0f" "$(echo "scale=2; $TERMINAL_HEIGHT * .5" | bc)") + +TERMINAL_WIDTH=$(tput cols) +BOX_WIDTH=$(printf "%.0f" "$(echo "scale=2; $TERMINAL_WIDTH * .75" | bc)") + +msg-box() { + whiptail --fb --msgbox "$1" "$BOX_HEIGHT" "$BOX_WIDTH" +} + +check-sudo-pass() { + log-info "Prompting user for sudo password with message: $1" + if [[ ! "$PASSWORD" ]]; then + PASSWORD=$(whiptail --fb --passwordbox "$1 Enter your sudo password" "$BOX_HEIGHT" "$BOX_WIDTH" 3>&2 2>&1 1>&3) + fi +} + +show-tail-box() { + trap "kill $2 2> /dev/null" EXIT + + while kill -0 "$2" 2> /dev/null; do + dialog --title "$1" --exit-label "Finished" --tailbox "$3" "$BOX_HEIGHT" "$BOX_WIDTH" + done + + clear + + trap - EXIT +} + +prompt-yes-no() { + declare action="$1" + log-info "Prompting user if they wish to proceed with $action" + + whiptail --fb --title "$action?" --yesno "Are you sure you wish to proceed with the specified action: $action?" --defaultno "$BOX_HEIGHT" "$BOX_WIDTH" +} \ No newline at end of file diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..3a992c7 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,315 @@ +use std::{env, time::Duration}; + +use anyhow::anyhow; + +use aws_sdk_dynamodb::Client; +use chrono::Utc; +use clap::Parser; +use elasticsearch::{ + auth::Credentials, + http::{ + transport::{SingleNodeConnectionPool, TransportBuilder}, + Url, + }, + indices::IndicesPutMappingParts, + Elasticsearch, +}; +use log::{error, info, warn, LevelFilter}; +use log4rs::{ + append::console::ConsoleAppender, + config::{Appender, Root}, + encode::pattern::PatternEncoder, +}; +use models::{DynamoDbSimulationMetrics, DynamoOperation}; +use rand::{ + rngs::{OsRng, StdRng}, + Rng, SeedableRng, +}; +use serde_json::json; +use tokio::{ + select, + sync::mpsc::{self, Receiver, Sender}, + task::JoinHandle, +}; +use tokio_util::sync::CancellationToken; + +use crate::{models::Scenario, simulators::Simulator}; + +mod models; +mod simulators; +mod timer_utils; + +#[derive(Parser)] +#[command(author, version, about, long_about = None)] +struct Cli { + /// The number of concurrent simulations to run + #[arg(short, long, default_value_t = 1000)] + concurrent_simulations: u32, + /// The number of attributes to use when populating and querying the DynamoDB table; minimum value of 1 + #[arg(short, long, default_value_t = 5)] + attributes: u32, + /// The length of time (in seconds) to run the benchmark for + #[arg(short, long, default_value_t = 1800)] + duration: u64, + /// The buffer size of the Elasticsearch thread's MPSC channel + #[arg(short, long, default_value_t = 500)] + buffer: usize, + /// Local Elasticsearch cluster username + #[arg(short, long, default_value_t = String::from("elastic"))] + username: String, + /// Local Elasticsearch cluster password + #[arg(short, long, default_value_t = String::from("changeme"))] + password: String, + /// The Elasticsearch Index to insert data into + #[arg(short, long, default_value_t = String::from("dynamodb"))] + index: String, + /// The DynamoDB table to perform operations against + #[arg(short, long, default_value_t = format!("{}-high-velocity-table", env::var("USER").unwrap()))] + table_name: String, + /// Whether to run a read-only scenario for benchmarking + #[arg(short, long)] + read_only: bool, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let cli = Cli::parse(); + log4rs::init_config(init_logging_config())?; + let cancellation_token = CancellationToken::new(); + + let (es_tx, es_rx) = mpsc::channel::(cli.buffer); + std::thread::spawn(move || { + start_elasticsearch_publisher(es_rx, cli.username, cli.password, cli.index) + }); + + let handles: Vec> = (0..cli.concurrent_simulations) + .map(|_| { + let tx = es_tx.clone(); + let token = cancellation_token.clone(); + let table_name = cli.table_name.clone(); + + tokio::spawn(async move { + let config = aws_config::load_from_env().await; + let dynamodb_client = Client::new(&config); + match scan_all_partition_keys(&dynamodb_client, table_name.clone()).await { + Ok(partition_keys_vec) => { + let simulator = Simulator::new( + &dynamodb_client, + table_name.clone(), + cli.attributes, + &partition_keys_vec, + ); + select! { + _ = token.cancelled() => { + warn!("Task cancelled. Shutting down..."); + } + _ = simulation_loop(simulator, cli.read_only, tx) => () + } + } + Err(e) => error!("Unable to fetch partition keys: {e:?}"), + } + }) + }) + .collect(); + + tokio::spawn(async move { + info!( + "Starting timer task. Executing for {} seconds", + cli.duration + ); + + tokio::time::sleep(Duration::from_secs(cli.duration)).await; + + cancellation_token.cancel(); + }); + + for handle in handles { + match handle.await { + Ok(_) => info!("Task shut down gracefully"), + Err(e) => warn!("Task did not shut down gracefully {e:?}"), + } + } + + Ok(()) +} + +#[tokio::main] +async fn start_elasticsearch_publisher( + mut elasticsearch_rx: Receiver, + username: String, + password: String, + index: String, +) -> anyhow::Result<()> { + let url = Url::parse("http://localhost:9200")?; + let connection_pool = SingleNodeConnectionPool::new(url); + let credentials = Credentials::Basic(username, password); + let transport = TransportBuilder::new(connection_pool) + .auth(credentials) + .build()?; + let es_client = Elasticsearch::new(transport); + + info!("Setting the explicit mappings for the {index} index"); + es_client + .indices() + .put_mapping(IndicesPutMappingParts::Index(&[&index])) + .body(json!({ + "properties": { + "timestamp": { + "type": "date" + } + } + })) + .send() + .await?; + + while let Some(metric) = elasticsearch_rx.recv().await { + info!("Publishing metrics to Elasticsearch..."); + + let es_response = es_client + .index(elasticsearch::IndexParts::Index(&index)) + .body(metric) + .send() + .await; + + match es_response { + Ok(resp) => { + if resp.status_code().is_success() { + info!("Successfully published metrics to Elasticsearch"); + } else { + error!("Was unable to publish metrics to Elasticsearch! Received non 2XX response"); + } + } + Err(e) => { + error!("Unable to publish metrics to Elasticsearch! {e:?}"); + } + } + } + + Ok(()) +} + +async fn simulation_loop( + mut simulator: Simulator<'_>, + read_only: bool, + tx: Sender, +) { + let mut rng = StdRng::from_seed(OsRng.gen()); + loop { + let mut metrics = DynamoDbSimulationMetrics::default(); + metrics.timestamp = Utc::now(); + + let simulation_time = time!(match { + if read_only { + info!("Running a read-only simulation..."); + metrics.scenario = Scenario::ReadOnly; + run_read_only_simulation(&mut simulator, &mut metrics, &mut rng).await + } else { + info!("Running a CRUD simulation..."); + metrics.scenario = Scenario::Crud; + run_crud_simulation(&mut simulator, &mut metrics, &mut rng).await + } + } { + Ok(_) => { + info!("Simulation completed successfully!"); + metrics.successful = true; + } + Err(e) => error!("Simulation did not complete. Encountered the following error: {e:?}"), + }); + metrics.simulation_time = Some(simulation_time); + info!("Metrics: {metrics:?}"); + + match tx.send(metrics).await { + Ok(_) => info!("Metrics sent down channel successfully"), + Err(e) => error!("Metrics were unable to be sent down the channel! {e:?}"), + } + } +} + +async fn run_read_only_simulation( + simulator: &mut Simulator<'_>, + metrics: &mut DynamoDbSimulationMetrics, + rng: &mut StdRng, +) -> anyhow::Result<()> { + tokio::time::sleep(Duration::from_secs(rng.gen_range(0..15))).await; + + metrics.operation = DynamoOperation::Read; + simulator.simulate_read_operation(metrics).await?; + + Ok(()) +} + +async fn run_crud_simulation( + simulator: &mut Simulator<'_>, + metrics: &mut DynamoDbSimulationMetrics, + rng: &mut StdRng, +) -> anyhow::Result<()> { + match DynamoOperation::from(rng.gen_range(0..3)) { + DynamoOperation::Read => { + metrics.operation = DynamoOperation::Read; + simulator.simulate_read_operation(metrics).await? + } + DynamoOperation::Write => { + metrics.operation = DynamoOperation::Write; + simulator.simulate_write_operation(metrics).await?; + } + DynamoOperation::Update => { + metrics.operation = DynamoOperation::Update; + simulator.simulate_update_operation(metrics).await?; + } + } + + Ok(()) +} + +async fn scan_all_partition_keys( + dynamodb_client: &Client, + table_name: String, +) -> anyhow::Result> { + info!("Fetching a large list of partition keys to randomly read..."); + let response = dynamodb_client + .scan() + .table_name(table_name) + .limit(10000) + .projection_expression("id") + .send() + .await; + + match response { + Ok(resp) => { + info!("Fetched partition keys!"); + let partition_keys = resp + .items() + .unwrap() + .into_iter() + .map(|attribute| { + attribute + .values() + .last() + .unwrap() + .as_s() + .unwrap() + .to_string() + }) + .collect::>(); + info!("Found a total of {} keys", partition_keys.len()); + Ok(partition_keys) + } + Err(e) => { + error!("Unable to fetch partition keys! {e:?}"); + Err(anyhow!(e)) + } + } +} + +fn init_logging_config() -> log4rs::Config { + let stdout = ConsoleAppender::builder() + .encoder(Box::new(PatternEncoder::new( + "{d(%Y-%m-%d %H:%M:%S%.3f)(utc)} <{i}> [{l}] {f}:{L} - {m}{n}", + ))) + .build(); + + log4rs::Config::builder() + .appender(Appender::builder().build("stdout", Box::new(stdout))) + .build(Root::builder().appender("stdout").build(LevelFilter::Info)) + .unwrap() +} diff --git a/src/models/mod.rs b/src/models/mod.rs new file mode 100644 index 0000000..e7f3131 --- /dev/null +++ b/src/models/mod.rs @@ -0,0 +1,102 @@ +use std::collections::HashMap; + +use aws_sdk_dynamodb::types::AttributeValue; +use chrono::{DateTime, Utc}; +use rand::Rng; +use serde::Serialize; +use serde_json::Number; +use uuid::Uuid; + +#[derive(Serialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub enum DynamoOperation { + #[default] + Read, + Write, + Update, +} + +impl From for DynamoOperation { + fn from(value: i32) -> Self { + match value { + 0 => DynamoOperation::Read, + 1 => DynamoOperation::Write, + 2 => DynamoOperation::Update, + _ => DynamoOperation::Read, + } + } +} + +#[derive(Serialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub enum Scenario { + #[default] + Crud, + ReadOnly, +} + +#[derive(Debug)] +pub struct BenchmarkingItem(HashMap); + +impl From> for BenchmarkingItem { + fn from(value: HashMap) -> BenchmarkingItem { + BenchmarkingItem(value) + } +} + +impl BenchmarkingItem { + pub fn new(attributes: u32) -> BenchmarkingItem { + let mut benchmarking_item = HashMap::::new(); + let mut rng = rand::thread_rng(); + benchmarking_item.insert( + "id".to_owned(), + AttributeValue::S(Uuid::new_v4().to_string()), + ); + + (0..attributes).for_each(|i| { + if let 0 = i % 2 { + benchmarking_item.insert(i.to_string(), AttributeValue::S(lipsum::lipsum_words(15))); + } else { + benchmarking_item.insert( + i.to_string(), + AttributeValue::N(rng.gen_range(0.0..=32.0).to_string()), + ); + } + }); + + BenchmarkingItem(benchmarking_item) + } + + pub fn get_id(&self) -> AttributeValue { + self.0.get("id").cloned().unwrap() + } + + pub fn insert(&mut self, key: &str, val: AttributeValue) -> Option { + self.0.insert(key.to_owned(), val) + } + + pub(crate) fn get(&self, key: &str) -> Option<&AttributeValue> { + self.0.get(key) + } + + pub fn extract_map(&self) -> HashMap { + self.0.clone() + } +} + +#[derive(Serialize, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct DynamoDbSimulationMetrics { + pub operation: DynamoOperation, + pub timestamp: DateTime, + pub successful: bool, + pub scenario: Scenario, + pub simulation_time: Option, + pub read_time: Option, + pub write_time: Option, + pub write_item_confirmation_time: Option, + pub update_time: Option, + pub update_item_confirmation_time: Option, + pub delete_time: Option, + pub delete_item_confirmation_time: Option, +} diff --git a/src/simulators/assertions.rs b/src/simulators/assertions.rs new file mode 100644 index 0000000..6aa93eb --- /dev/null +++ b/src/simulators/assertions.rs @@ -0,0 +1,72 @@ +use aws_sdk_dynamodb::types::AttributeValue; +use log::{error, info}; + +use crate::{models::DynamoDbSimulationMetrics, time}; + +use super::{utils, Simulator}; + +impl<'a> Simulator<'a> { + pub(super) async fn assert_item_was_created( + &mut self, + id: AttributeValue, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + let partition_key = utils::extract_partition_key(id.clone()); + let mut attempts_exhausted = false; + + let write_confirmation_time = time!(for i in 0..10 { + info!("Attempt {i}: Fetching newly added item with partition key: {partition_key}"); + + match self.read_item(id.clone(), metrics, false).await? { + Some(_) => { + info!("Successfully read new item with partition key: {partition_key}"); + break; + } + None => { + error!("Unable to find new item with partition key: {partition_key}"); + if i == 9 { + error!("All attempts to fetch the newly added item with partition key: {partition_key} failed!"); + attempts_exhausted = true; + } + } + }; + }); + + if !attempts_exhausted { + metrics.write_item_confirmation_time = Some(write_confirmation_time); + } + + Ok(()) + } + + pub(super) async fn assert_item_was_deleted( + &mut self, + id: AttributeValue, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + let partition_key = utils::extract_partition_key(id.clone()); + let mut attempts_exhausted = false; + let delete_confirmation_time = time!(for i in 0..10 { + info!("Attempt {i}: Fetching deleted item with partition key: {partition_key}..."); + match self.read_item(id.clone(), metrics, false).await? { + Some(_) => { + error!("Item with partition key {partition_key} was not deleted as expected!"); + if i == 9 { + error!("All attempts to receive an empty response to verify item with partition key: {partition_key} was deleted failed!"); + attempts_exhausted = true; + } + } + None => { + info!("Item with partition key {partition_key} was successfully deleted."); + break; + } + } + }); + + if !attempts_exhausted { + metrics.delete_item_confirmation_time = Some(delete_confirmation_time); + } + + Ok(()) + } +} diff --git a/src/simulators/mod.rs b/src/simulators/mod.rs new file mode 100644 index 0000000..35f4fa6 --- /dev/null +++ b/src/simulators/mod.rs @@ -0,0 +1,140 @@ +use aws_sdk_dynamodb::{types::AttributeValue, Client}; +use log::{error, info}; +use rand::{ + rngs::{OsRng, StdRng}, + Rng, SeedableRng, +}; + +use crate::{models::DynamoDbSimulationMetrics, time}; + +mod assertions; +mod operations; +mod utils; + +pub struct Simulator<'a> { + dynamodb_client: &'a Client, + table_name: String, + attributes: u32, + partition_keys_vec: &'a [String], + rng: StdRng, +} + +impl<'a> Simulator<'a> { + pub fn new( + dynamodb_client: &'a Client, + table_name: String, + attributes: u32, + partition_keys_vec: &'a [String], + ) -> Simulator<'a> { + Simulator { + dynamodb_client, + table_name, + attributes, + partition_keys_vec, + rng: StdRng::from_seed(OsRng.gen()), + } + } + + pub async fn simulate_read_operation( + &mut self, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + info!("Performing READ Operation..."); + let partition_key = + self.partition_keys_vec[self.rng.gen_range(0..self.partition_keys_vec.len())].clone(); + let id = AttributeValue::S(partition_key.clone()); + + for i in 0..10 { + info!("Attempt {i}: Fetching existing item with partition key: {partition_key}"); + + match self.read_item(id.clone(), metrics, true).await? { + Some(_) => { + info!("Successfully read existing item with partition key: {partition_key}"); + break; + } + None => { + error!("Unable to find existing item with partition key: {partition_key}"); + if i == 9 { + error!( + "All attempts to fetch the existing item with partition key: {partition_key} failed!" + ); + } + } + } + } + + Ok(()) + } + + pub async fn simulate_write_operation( + &mut self, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + info!("Performing WRITE operation..."); + let benchmarking_item = self.put_item(metrics).await?; + let id = benchmarking_item.get_id(); + + self.assert_item_was_created(id.clone(), metrics).await?; + + self.delete_item(id.clone(), metrics).await?; + + self.assert_item_was_deleted(id, metrics).await?; + + Ok(()) + } + + pub async fn simulate_update_operation( + &mut self, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + info!("Performing UPDATE operation..."); + let new_item = self.put_item(metrics).await?; + let id = new_item.get_id(); + let partition_key = utils::extract_partition_key(id.clone()); + let mut attempts_exhausted = false; + + self.assert_item_was_created(id.clone(), metrics).await?; + self.update_item(id.clone(), metrics).await?; + + let update_confirmation_time = time!(for i in 0..10 { + info!("Attempt {i}: Fetching updated item for partition key: {partition_key}..."); + + let updated_item = self.read_item(id.clone(), metrics, false).await?.unwrap(); + + let new_item_attribute_value = new_item + .get("1") + .cloned() + .unwrap() + .as_n() + .unwrap() + .to_string(); + let updated_item_attribute_value = updated_item + .get("1") + .cloned() + .unwrap() + .as_n() + .unwrap() + .to_string(); + + if new_item_attribute_value != updated_item_attribute_value { + info!("Confirmed update for partition key: {partition_key}"); + break; + } else { + error!("Update for partition key {partition_key} failed! Values are still equal!"); + if i == 9 { + error!("Exhausted attempts to fetch updated item!"); + attempts_exhausted = true; + } + } + }); + + if !attempts_exhausted { + metrics.update_item_confirmation_time = Some(update_confirmation_time); + } + + self.delete_item(id.clone(), metrics).await?; + self.assert_item_was_deleted(id, metrics).await?; + + Ok(()) + } +} diff --git a/src/simulators/operations.rs b/src/simulators/operations.rs new file mode 100644 index 0000000..8acff39 --- /dev/null +++ b/src/simulators/operations.rs @@ -0,0 +1,144 @@ +use anyhow::anyhow; +use aws_sdk_dynamodb::types::AttributeValue; +use log::{error, info}; + +use crate::{ + models::{BenchmarkingItem, DynamoDbSimulationMetrics}, + time, +}; + +use super::{utils::extract_partition_key, Simulator}; + +impl<'a> Simulator<'a> { + pub async fn read_item( + &mut self, + id: AttributeValue, + metrics: &mut DynamoDbSimulationMetrics, + record_metrics: bool, + ) -> anyhow::Result> { + let partition_key = extract_partition_key(id.clone()); + let (read_time, response) = time!( + resp, + self + .dynamodb_client + .get_item() + .table_name(self.table_name.clone()) + .key("id", id) + .send() + .await + ); + + if record_metrics { + metrics.read_time = Some(read_time); + } + + match response { + Ok(resp) => { + info!("Found item: {}", partition_key); + if let Some(item) = resp.item() { + info!("Fetched item: {item:?}"); + Ok(Some(BenchmarkingItem::from(item.clone()))) + } else { + info!("No items found with partition key: {partition_key}"); + Ok(None) + } + } + Err(e) => { + error!("Could not fetch item with partition key: {partition_key}. {e:?}"); + Err(anyhow!(e)) + } + } + } + + pub async fn update_item( + &mut self, + id: AttributeValue, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + let mut updated_item = BenchmarkingItem::new(self.attributes); + updated_item.insert("id", id.clone()); + let partition_key = extract_partition_key(id); + let (update_time, response) = time!( + resp, + self + .dynamodb_client + .put_item() + .table_name(self.table_name.clone()) + .set_item(Some(updated_item.extract_map())) + .send() + .await + ); + metrics.update_time = Some(update_time); + + match response { + Ok(_) => { + info!("Successfully updated item with partition_key: {partition_key}"); + Ok(()) + } + Err(e) => { + error!("Could not update item with partition key: {partition_key}. {e:?}"); + Err(anyhow!(e)) + } + } + } + + pub async fn put_item( + &mut self, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result { + let new_item = BenchmarkingItem::new(self.attributes); + let partition_key = extract_partition_key(new_item.get("id").cloned().unwrap()); + let (time, response) = time!( + resp, + self + .dynamodb_client + .put_item() + .table_name(self.table_name.clone()) + .set_item(Some(new_item.extract_map())) + .send() + .await + ); + metrics.write_time = Some(time); + + match response { + Ok(_) => { + info!("Successfully put new item with partition key: {partition_key}"); + Ok(new_item) + } + Err(e) => { + error!("Could not put new item with partition key: {partition_key}. {e:?}"); + Err(anyhow!(e)) + } + } + } + + pub async fn delete_item( + &mut self, + id: AttributeValue, + metrics: &mut DynamoDbSimulationMetrics, + ) -> anyhow::Result<()> { + let partition_key = extract_partition_key(id.clone()); + let (delete_time, response) = time!( + resp, + self + .dynamodb_client + .delete_item() + .table_name(self.table_name.clone()) + .key("id", id) + .send() + .await + ); + metrics.delete_time = Some(delete_time); + + match response { + Ok(_) => { + info!("Successfully deleted item with partition key: {partition_key}"); + Ok(()) + } + Err(e) => { + error!("Could not delete item with partition key: {partition_key}. {e:?}"); + Err(anyhow!(e)) + } + } + } +} diff --git a/src/simulators/utils.rs b/src/simulators/utils.rs new file mode 100644 index 0000000..722ca04 --- /dev/null +++ b/src/simulators/utils.rs @@ -0,0 +1,5 @@ +use aws_sdk_dynamodb::types::AttributeValue; + +pub(super) fn extract_partition_key(id: AttributeValue) -> String { + id.clone().as_s().unwrap().to_string() +} diff --git a/src/timer_utils.rs b/src/timer_utils.rs new file mode 100644 index 0000000..d8a4153 --- /dev/null +++ b/src/timer_utils.rs @@ -0,0 +1,14 @@ +#[macro_export] +macro_rules! time { + ($x:expr) => {{ + let start = std::time::Instant::now(); + let _result = $x; + serde_json::Number::from(start.elapsed().as_millis()) + }}; + + ($resp:ident, $x:expr) => {{ + let start = std::time::Instant::now(); + let $resp = $x; + (serde_json::Number::from(start.elapsed().as_millis()), $resp) + }}; +}