diff --git a/CHANGELOG.md b/CHANGELOG.md index b9556b7..4b731b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,81 @@ # Changelog +## [2.0.4] - 2026-07-26 + +Kubernetes is no longer required. Benchmarks can run as local containers under +podman or docker, and HammerDB moves to 6.0. + +### Added +- Container backend: set `backend: podman` or `backend: docker` to run one + HammerDB container per target on a single host, with no cluster, Helm or + kubectl. Verified equivalent to Kubernetes on throughput across SQL Server, + Oracle and PostgreSQL at 1, 4 and 8 targets. +- PostgreSQL support, including TPC-C and TPC-H scripts and schema build. +- Transaction response times in the scorecard. HammerDB 6.0 reports per + transaction percentiles from reservoir sampling, so p50/p95/p99 and max are + exact rather than bucket approximations. `time_profile` now defaults on; the + overhead measured smaller than run-to-run variance. +- `validate --from-cluster` checks database reachability from where the + workers actually run, which is not always reachable from your workstation. +- Credentials pass through a Kubernetes Secret by default, so they are not + readable via `kubectl describe job`. +- Pod securityContext compatible with the restricted Pod Security Standard. +- `resources.limits` applies to the container backend as `--memory`/`--cpus`. +- `-c` and `--config` accepted as aliases for `--file`. + +### Changed +- HammerDB 6.0. Existing 5.0-built schemas are read without rebuilding, and + the published `latest` images now contain 6.0. Verified that an unmodified + 2.0.3 install still runs against them. +- `init` asks where to run the workers and emits only the relevant backend + block, instead of always asking for a Kubernetes namespace. +- README leads with the container path; Kubernetes is documented as the option + for people who already have a cluster. +- The repo-root chart paths are symlinks into the packaged chart rather than a + second copy, which had already drifted. + +### Fixed +- `total_iterations` rendered in scientific notation (`1e+07`) in the Helm + template, which TCL cannot parse. +- `find_test_ids` returned a stale build run ahead of the completed run, so + `results` reported no metrics after a successful benchmark. +- Container runs carried no deployment-name label, so two configs sharing a + host saw each other's test IDs. +- The base image was missing `libpq5`, so PostgreSQL could never load its + driver despite being advertised as supported. +- `securityContext` and `use_secrets` opt-outs were inert, because Go's + `default true` treats an explicit `false` as absent. +- `validate --from-cluster` probed every non-Oracle target on the SQL Server + port, reporting healthy PostgreSQL fleets as unreachable. +- The `:z` SELinux mount relabel was applied unconditionally, which breaks + Docker Desktop. It is now gated on the host actually enforcing SELinux. +- `init` produced SQL Server ODBC settings when PostgreSQL was selected. +- `VERSION` reported `2.0.2` in the 2.0.3 release; it now reads from + `pyproject.toml`. + +### Known limitations +- Oracle requires Oracle Instant Client, which Oracle publishes for linux.x64 + only, so Oracle benchmarks cannot run on Apple Silicon. +- Oracle ships as a separate image you build yourself, because Oracle's licence + does not permit redistributing Instant Client. +- The container backend drives from a single host. Spreading workers across + hosts requires the Kubernetes backend. +- Verified on Linux with podman. The docker, macOS and WSL2 paths are + implemented and unit tested but have not been exercised end to end. + +## [2.0.3] - 2026-07-11 + +### Fixed +- Test ID resolution now filters by deployment name, so `results` and `report` + act on the intended run. + +## [2.0.2] - 2026-07-10 + +### Fixed +- Run hash generation mis-parsed YAML values in scientific notation. +- Helm chart version aligned with the CLI release. +- LICENSE badge link corrected for PyPI rendering. + ## [2.0.1] - 2026-03-05 ### Added @@ -11,7 +87,7 @@ - Input validation and Ctrl+C handling ### Fixed -- README.md documentation links broken on PyPI — relative paths like `docs/CONFIGURATION.md` resolved against `pypi.org` instead of GitHub. Converted all links to absolute GitHub URLs. +- README.md documentation links broken on PyPI: relative paths like `docs/CONFIGURATION.md` resolved against `pypi.org` instead of GitHub. Converted all links to absolute GitHub URLs. ## [2.0.0] - 2026-03-01 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..de232bb --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# CLAUDE.md — HammerDB-Scale + +## Communication style + +Andrew is the director on this project. Communicate at that level. + +- Write in proper prose with correct punctuation. Full sentences, not fragments. +- Do not use em dashes. Use commas, colons, semicolons, or separate sentences instead. +- Lead with the answer or the recommendation, then supply the reasoning. Do not build up to a conclusion. +- Do not dump raw information. Synthesise it. If there are five findings, say which two matter and why. +- Give a clear recommendation when asked a decision question. Do not present a neutral menu of options and leave the choice hanging. +- Quantify effort and risk in concrete terms (days of work, files touched, what breaks) rather than vague labels. +- Flag genuine trade-offs and things that will bite later, but keep it brief and do not editorialise. +- Skip filler openings and closings. No "Great question", no "Let me know if you need anything else". +- Avoid heavy bold-and-bullet formatting where a short paragraph reads better. + +## Project overview + +HammerDB-Scale orchestrates parallel HammerDB database benchmarks across multiple +database instances at once. It is used mainly for storage platform validation: +proving how a Pure Storage FlashArray behaves when serving N database workloads +concurrently. + +Current shape: a Python CLI (`hammerdb_scale`) that generates Helm values, deploys +one Kubernetes Job per database target, then collects and parses the job logs into +aggregated results and a self-contained HTML scorecard. + +- `src/hammerdb_scale/` — the CLI package (see MEMORY.md for the module map) +- `src/hammerdb_scale/chart/` — the bundled Helm chart, the single source of truth +- `templates/`, `scripts/`, `Chart.yaml`, `values.yaml` — symlinks into the chart above +- `src/hammerdb_scale/runtime/` — execution backends (Kubernetes and container) +- `entrypoint.sh` — the in-container dispatcher that selects and runs a TCL script +- `Dockerfile`, `Dockerfile.oracle` — base image and Oracle Instant Client extension +- `hack/build-images.sh` — builds and verifies both images + +### Things worth knowing + +- The repo-root chart paths are symlinks, not copies. They used to be duplicates and + had already drifted; `get_chart_path()` prefers the packaged copy, so a fix applied + only at the repo root would never reach anyone installing from PyPI. +- The HammerDB version lives in `ARG HAMMERDB_VERSION` and flows through + `HAMMERDB_HOME`. `entrypoint.sh` *searches* for its mounted scripts rather than + requiring the chart's mountPath to match the image, because those are set by + different artifacts and can always disagree. +- `DEFAULT_HAMMERDB_VERSION` is what this repo builds; `PUBLISHED_HAMMERDB_VERSION` + is what the published images contain and drives the chart default. Move the latter + only after pushing new images. +- Database support is gated in five places: the `DatabaseType` enum in + `config/schema.py`, the `case` blocks in `entrypoint.sh`, the parser registry in + `results/parsers.py`, the per-database TCL script directories, and the per-database + ConfigMap template in the chart. +- The base image needs `libpq5` for PostgreSQL: HammerDB bundles Pgtcl but links the + system libpq at runtime. +- Credentials go through a Kubernetes Secret by default (`kubernetes.use_secrets`), + so they are not readable via `kubectl describe job`. + +## Working preferences + +- Do not use `sed` or shell scripts to edit config files. Use the Edit tool per file. +- Run `pytest tests/ -q` before declaring work done. The suite is fast (under a second). diff --git a/Chart.yaml b/Chart.yaml deleted file mode 100644 index ca9252b..0000000 --- a/Chart.yaml +++ /dev/null @@ -1,19 +0,0 @@ -apiVersion: v2 -name: hammerdb-scale -description: Modular Helm chart for parallel HammerDB scale testing across multiple databases (SQL Server, PostgreSQL, Oracle, MySQL) -type: application -version: 2.0.2 -appVersion: "5.0" -keywords: - - hammerdb - - benchmark - - tpc-c - - tpc-h - - sql-server - - postgresql - - oracle - - mysql - - load-testing - - performance -maintainers: - - name: Silli, Nocentino \ No newline at end of file diff --git a/Chart.yaml b/Chart.yaml new file mode 120000 index 0000000..d084241 --- /dev/null +++ b/Chart.yaml @@ -0,0 +1 @@ +src/hammerdb_scale/chart/Chart.yaml \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..1a100bb --- /dev/null +++ b/Dockerfile @@ -0,0 +1,99 @@ +# HammerDB Scale - Base Image +# +# Verified working: SQL Server and PostgreSQL. +# Oracle needs the extension image (Dockerfile.oracle) because Oracle Instant +# Client cannot be redistributed. +# +# ARCHITECTURE: x86_64. HammerDB 6.0 does publish ARM64 tarballs and this image +# would build for ARM, but the Oracle extension cannot (Instant Client is +# x86_64-only), so the pair is kept uniformly x86_64 to avoid shipping a base +# image that works on ARM alongside an Oracle image that does not. +# +# BUILD: +# docker build -t sillidata/hammerdb-scale:6.0 . +# +# To build a different HammerDB version: +# docker build --build-arg HAMMERDB_VERSION=5.0 -t sillidata/hammerdb-scale:5.0 . +# +# Prefer hack/build-images.sh, which builds this and the Oracle +# image in the right order and verifies both. + +FROM ubuntu:24.04 + +# The HammerDB version is defined once here and flows into the install path, +# HAMMERDB_HOME and the image label. entrypoint.sh reads HAMMERDB_HOME rather +# than hardcoding a path, so a version bump is this one argument. +ARG HAMMERDB_VERSION=6.0 + +# Provenance. Populated by the release script; harmless when built by hand. +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +ARG IMAGE_VERSION=dev + +LABEL maintainer="hammerdb-scale" +LABEL description="HammerDB Scale Test Runner - Multi-Database Performance Testing" +LABEL hammerdb.version="${HAMMERDB_VERSION}" +LABEL database.support="mssql,postgresql (oracle via extension image)" +LABEL org.opencontainers.image.title="hammerdb-scale" +LABEL org.opencontainers.image.description="HammerDB Scale Test Runner - Multi-Database Performance Testing" +LABEL org.opencontainers.image.source="https://github.com/PureStorage-OpenConnect/hammerdb-scale" +LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.version="${IMAGE_VERSION}" +LABEL org.opencontainers.image.revision="${VCS_REF}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +# Set environment variables +ENV DEBIAN_FRONTEND=noninteractive +ENV HAMMERDB_HOME=/opt/HammerDB-${HAMMERDB_VERSION} + +# Install base packages and SQL Server drivers (Microsoft - permissive license) +RUN apt-get update && \ + apt-get install -y \ + apt-transport-https \ + curl \ + gnupg2 \ + wget \ + python3 \ + python3-pip \ + vim && \ + # Add Microsoft repository for SQL Server tools + curl -sSL -O https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb && \ + dpkg -i packages-microsoft-prod.deb && \ + rm packages-microsoft-prod.deb && \ + apt-get update && \ + ACCEPT_EULA=Y apt-get install -y mssql-tools18 msodbcsql18 unixodbc unixodbc-dev && \ + # HammerDB bundles Pgtcl but links against the system libpq at runtime; + # without libpq5 the PostgreSQL driver fails with + # "Failed to load Pgtcl - libpq.so.5: cannot open shared object file". + # postgresql-client provides psql for diagnostics. + apt-get install -y libpq5 postgresql-client && \ + echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc && \ + apt-get clean && \ + rm -rf /var/lib/apt/lists/* /var/apt/cache/* /tmp/* /var/tmp/* + +# Install Python dependencies for Pure Storage metrics collection +RUN python3 -m pip install --no-cache-dir --break-system-packages requests urllib3 + +# Install HammerDB +WORKDIR /opt +RUN wget "https://github.com/TPC-Council/HammerDB/releases/download/v${HAMMERDB_VERSION}/HammerDB-${HAMMERDB_VERSION}-Prod-Lin-UBU24.tar.gz" && \ + tar -xzf "HammerDB-${HAMMERDB_VERSION}-Prod-Lin-UBU24.tar.gz" && \ + rm "HammerDB-${HAMMERDB_VERSION}-Prod-Lin-UBU24.tar.gz" && \ + echo 'export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/:$LD_LIBRARY_PATH' >> ~/.bashrc + +# Configure HammerDB +RUN chmod +x "${HAMMERDB_HOME}/hammerdbcli" && \ + ln -sf /opt/mssql-tools18/bin/bcp "${HAMMERDB_HOME}/bcp" && \ + ln -sf /opt/mssql-tools18/bin/bcp /usr/local/bin/bcp + +# Add entrypoint script +COPY entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +# Add Pure Storage metrics collection script +COPY scripts/collect_pure_metrics.py ${HAMMERDB_HOME}/scripts/collect_pure_metrics.py +RUN chmod +x "${HAMMERDB_HOME}/scripts/collect_pure_metrics.py" + +WORKDIR ${HAMMERDB_HOME} + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/Dockerfile.oracle b/Dockerfile.oracle index 3f30b65..e7112f8 100644 --- a/Dockerfile.oracle +++ b/Dockerfile.oracle @@ -1,22 +1,63 @@ # Oracle Extension Image -# Extends the public hammerdb-scale image with Oracle Instant Client +# Extends the hammerdb-scale base image with Oracle Instant Client. # # LICENSING: Oracle Instant Client is proprietary software. By building this # image, you download Oracle software directly from Oracle's servers and agree # to Oracle's license terms: https://www.oracle.com/downloads/licenses/instant-client-lic.html +# This is why Oracle support ships as a separate image you build yourself +# rather than being part of the base image. # -# BUILD: -# docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:latest . -# docker push myregistry/hammerdb-scale-oracle:latest +# ARCHITECTURE: x86_64 only. Oracle publishes Instant Client for linux.x64 at +# the URLs used below; there is no ARM64 equivalent in this download path. # -# The base image (sillidata/hammerdb-scale) includes HammerDB 5.0 and drivers for -# SQL Server, PostgreSQL, and MySQL. This extension adds Oracle support. +# BUILD (against the published base): +# docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:6.0 . +# +# BUILD (against a locally built base — required when bumping HammerDB, since +# the published base may still be an older version): +# docker build -f Dockerfile.oracle \ +# --build-arg BASE_IMAGE=hammerdb-scale:6.0 \ +# -t hammerdb-scale-oracle:6.0 . +# +# Prefer hack/build-images.sh, which wires the base image through +# correctly and verifies the result. + +ARG BASE_IMAGE=sillidata/hammerdb-scale:latest +FROM ${BASE_IMAGE} -FROM sillidata/hammerdb-scale:latest +# Instant Client version, defined once. It appears in the download URLs, the +# install directory, the ld.so config and the libclntsh soname, so hardcoding +# it meant a client upgrade was a five-line edit with no way to verify you had +# caught every occurrence. +# IC_VERSION full version, used in filenames (21.11.0.0.0) +# IC_DIR_VER directory suffix (21_11) +# IC_URL_VER version path segment in the URL (2111000) +# IC_SONAME libclntsh major.minor (21.1) +ARG IC_VERSION=21.11.0.0.0 +ARG IC_DIR_VER=21_11 +ARG IC_URL_VER=2111000 +ARG IC_SONAME=21.1 + +# Provenance. Populated by the release script; harmless when built by hand. +ARG VCS_REF=unknown +ARG BUILD_DATE=unknown +ARG IMAGE_VERSION=dev LABEL maintainer="hammerdb-scale" LABEL description="HammerDB Scale - Oracle Extension" -LABEL oracle.instantclient.version="21.11.0.0.0" +LABEL oracle.instantclient.version="${IC_VERSION}" +LABEL org.opencontainers.image.title="hammerdb-scale-oracle" +LABEL org.opencontainers.image.description="HammerDB Scale runner with Oracle Instant Client" +LABEL org.opencontainers.image.source="https://github.com/PureStorage-OpenConnect/hammerdb-scale" +LABEL org.opencontainers.image.licenses="Apache-2.0" +LABEL org.opencontainers.image.version="${IMAGE_VERSION}" +LABEL org.opencontainers.image.revision="${VCS_REF}" +LABEL org.opencontainers.image.created="${BUILD_DATE}" + +ENV ORACLE_HOME=/opt/oracle/instantclient_${IC_DIR_VER} +ENV LD_LIBRARY_PATH=/opt/oracle/instantclient_${IC_DIR_VER}:$LD_LIBRARY_PATH +ENV PATH=/opt/oracle/instantclient_${IC_DIR_VER}:$PATH +ENV TNS_ADMIN=/opt/oracle/instantclient_${IC_DIR_VER}/network/admin # Install Oracle Instant Client dependencies and download from Oracle RUN apt-get update && \ @@ -24,40 +65,33 @@ RUN apt-get update && \ wget \ unzip \ libaio1t64 && \ - # Create symlink for libaio compatibility (Ubuntu 24.04) - ln -s /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 && \ - # Download Oracle Instant Client from Oracle's servers + # Ubuntu 24.04 ships libaio as .so.1t64; Instant Client links against .so.1 + ln -sf /usr/lib/x86_64-linux-gnu/libaio.so.1t64 /usr/lib/x86_64-linux-gnu/libaio.so.1 && \ mkdir -p /opt/oracle && \ cd /opt/oracle && \ - echo "Downloading Oracle Instant Client 21.11 from Oracle..." && \ + echo "Downloading Oracle Instant Client ${IC_VERSION} from Oracle..." && \ + BASE_URL="https://download.oracle.com/otn_software/linux/instantclient/${IC_URL_VER}" && \ wget --progress=dot:mega \ - https://download.oracle.com/otn_software/linux/instantclient/2111000/instantclient-basic-linux.x64-21.11.0.0.0dbru.zip && \ + "${BASE_URL}/instantclient-basic-linux.x64-${IC_VERSION}dbru.zip" && \ wget --progress=dot:mega \ - https://download.oracle.com/otn_software/linux/instantclient/2111000/instantclient-sqlplus-linux.x64-21.11.0.0.0dbru.zip && \ - unzip -q instantclient-basic-linux.x64-21.11.0.0.0dbru.zip && \ - unzip -q instantclient-sqlplus-linux.x64-21.11.0.0.0dbru.zip && \ - rm *.zip && \ - # Configure library paths - echo '/opt/oracle/instantclient_21_11' > /etc/ld.so.conf.d/oracle.conf && \ + "${BASE_URL}/instantclient-sqlplus-linux.x64-${IC_VERSION}dbru.zip" && \ + unzip -q "instantclient-basic-linux.x64-${IC_VERSION}dbru.zip" && \ + unzip -q "instantclient-sqlplus-linux.x64-${IC_VERSION}dbru.zip" && \ + rm ./*.zip && \ + echo "/opt/oracle/instantclient_${IC_DIR_VER}" > /etc/ld.so.conf.d/oracle.conf && \ ldconfig && \ - # Cleanup apt-get clean && \ rm -rf /var/lib/apt/lists/* -# Set Oracle environment variables -ENV ORACLE_HOME=/opt/oracle/instantclient_21_11 -ENV LD_LIBRARY_PATH=$ORACLE_HOME:$LD_LIBRARY_PATH -ENV PATH=$ORACLE_HOME:$PATH -ENV TNS_ADMIN=$ORACLE_HOME/network/admin - -# Create lib subdirectory symlinks for HammerDB's Oratcl compatibility -RUN mkdir -p $ORACLE_HOME/lib && \ - cd $ORACLE_HOME && \ +# HammerDB's Oratcl looks for the client libraries under $ORACLE_HOME/lib, +# but Instant Client unpacks them flat into $ORACLE_HOME. +RUN mkdir -p "${ORACLE_HOME}/lib" && \ + cd "${ORACLE_HOME}" && \ for lib in *.so*; do \ - [ -f "$lib" ] && ln -sf $ORACLE_HOME/$lib $ORACLE_HOME/lib/$lib; \ + [ -f "$lib" ] && ln -sf "${ORACLE_HOME}/${lib}" "${ORACLE_HOME}/lib/${lib}"; \ done && \ - ln -sf $ORACLE_HOME/libclntsh.so.21.1 $ORACLE_HOME/libclntsh.so && \ - ln -sf $ORACLE_HOME/libclntsh.so.21.1 $ORACLE_HOME/lib/libclntsh.so + ln -sf "${ORACLE_HOME}/libclntsh.so.${IC_SONAME}" "${ORACLE_HOME}/libclntsh.so" && \ + ln -sf "${ORACLE_HOME}/libclntsh.so.${IC_SONAME}" "${ORACLE_HOME}/lib/libclntsh.so" -# Verify Oracle client is working +# Fail the build here rather than at benchmark time if the client is broken. RUN sqlplus -v && echo "Oracle Instant Client ready" diff --git a/README.md b/README.md index 17d0520..2e5e14b 100644 --- a/README.md +++ b/README.md @@ -4,44 +4,63 @@ [![Python versions](https://img.shields.io/pypi/pyversions/hammerdb-scale)](https://pypi.org/project/hammerdb-scale/) [![License](https://img.shields.io/pypi/l/hammerdb-scale)](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/LICENSE) -A Python CLI for orchestrating parallel HammerDB database benchmarks at scale on Kubernetes. +A Python CLI for orchestrating parallel HammerDB database benchmarks at scale. ## What is HammerDB-Scale? -HammerDB-Scale runs **synchronized database performance tests across multiple database instances simultaneously**. It deploys HammerDB as Kubernetes Jobs targeting multiple databases in parallel, making it ideal for: +HammerDB-Scale runs **synchronized database performance tests across multiple database instances simultaneously**, making it ideal for: - **Storage Platform Testing**: Validate storage array performance under realistic multi-database workloads - **Scale Testing**: Test how storage performs when serving 2, 4, 8+ databases concurrently - **Capacity Planning**: Understand how many database workloads your storage can support -## How It Works +It runs one HammerDB container per database target. Those containers can run +on a single machine with podman or docker, or as Jobs on a Kubernetes cluster. +Most people should start with containers: there is nothing to install beyond a +container runtime, and the driver is light enough that one host comfortably +saturates a large fleet of databases. -HammerDB-Scale is a CLI orchestrator that sits on your workstation and drives benchmarks through Kubernetes: +## How It Works ``` - +--------------------------------------+ - | Kubernetes Cluster | -hammerdb-scale CLI | | - (your machine) | +-----------+ +--------------+ | - | | | HammerDB |--->| Database 1 | | - | helm install | | Job 1 | +--------------+ | - |---------------->| +-----------+ | - | | +-----------+ +--------------+ | - | kubectl logs | | HammerDB |--->| Database 2 | | - |---------------->| | Job 2 | +--------------+ | - | | +-----------+ | - | results/report | +-----------+ +--------------+ | - | | | HammerDB |--->| Database N | | - | | | Job N | +--------------+ | - | | +-----------+ | - | +--------------------------------------+ +hammerdb-scale CLI +-----------+ +--------------+ + (your machine, or a | HammerDB |--->| Database 1 | + host near the databases) | worker 1 | +--------------+ + | +-----------+ + | deploy +-----------+ +--------------+ + |------------------->| HammerDB |--->| Database 2 | + | | worker 2 | +--------------+ + | collect logs +-----------+ + |<-------------------+-----------+ +--------------+ + | | HammerDB |--->| Database N | + | results/report | worker N | +--------------+ + v +-----------+ + scorecard.html ``` 1. You define your database targets and benchmark parameters in a YAML config file -2. The CLI translates your config into Helm values and deploys one Kubernetes Job per database target -3. Each Job runs a HammerDB container that connects to its assigned database and executes the benchmark -4. All Jobs run in parallel, producing synchronized load across all targets -5. The CLI collects results from Job logs, aggregates metrics (TPM/NOPM for TPC-C, QphH for TPC-H), and generates an HTML scorecard +2. The CLI starts one HammerDB worker per database target, as a local container or a Kubernetes Job +3. Each worker connects to its assigned database and executes the benchmark +4. All workers run in parallel, producing synchronized load across all targets +5. The CLI collects results, aggregates metrics (TPM/NOPM for TPC-C, QphH for TPC-H), and generates an HTML scorecard + +### Where to run the workers + +Put the driver host close to the databases. Driver-to-database latency counts +against your benchmark numbers, so running from a laptop over a VPN will make +your storage look slower than it is. + +| You have | Use | Notes | +|---|---|---| +| A Linux host near the databases | `backend: podman` | Recommended. No cluster needed. | +| A laptop with Docker Desktop | `backend: docker` | Fine for smaller runs; mind the network path. | +| An existing Kubernetes cluster | `backend: kubernetes` | Schedules workers across nodes. | +| Databases spread across sites | `backend: kubernetes` | The container backend drives from one host only. | + +A worker is a TCL process issuing SQL and waiting on the network, so it costs +very little: measured at roughly 57 MB and a fifth of a CPU core while driving +8 virtual users at ~100,000 TPM. Eight concurrent targets on one host used +under 500 MB in total. ## Quick Start @@ -49,24 +68,55 @@ hammerdb-scale CLI | | # Install pip install hammerdb-scale -# Generate config interactively +# Generate config interactively (asks where to run the workers) hammerdb-scale init -# Validate config and database connectivity +# Check the config and that every database answers hammerdb-scale validate -# Build schema, run benchmark, collect results +# Build schemas, run the benchmark, collect results hammerdb-scale run --build --wait hammerdb-scale results hammerdb-scale report --open ``` +A minimal config for two SQL Server targets on a machine with podman: + +```yaml +name: storage-validation +backend: podman + +targets: + defaults: + type: mssql + username: sa + password: "your-password" + hosts: + - name: sql-01 + host: 10.0.0.11 + - name: sql-02 + host: 10.0.0.12 + +hammerdb: + tprocc: + warehouses: 1000 + load_virtual_users: 8 + duration: 10 +``` + +See [examples/](examples/) for complete configs per database. + +**Budget time for the schema build.** It is a separate phase and it dominates +the wall clock: 100 warehouses takes minutes, 10,000 warehouses takes hours. +Build once, then run as many benchmarks against that schema as you like. + ## Supported Databases | Database | Benchmarks | Container Image | |----------|-----------|-----------------| | **SQL Server** | TPC-C, TPC-H | `sillidata/hammerdb-scale:latest` | -| **Oracle** | TPC-C, TPC-H | `sillidata/hammerdb-scale-oracle:latest` | +| **PostgreSQL** | TPC-C, TPC-H | `sillidata/hammerdb-scale:latest` | +| **Oracle** | TPC-C, TPC-H | build locally, see [Platform support](#platform-support) | ## Workflow @@ -75,8 +125,8 @@ init → validate → run --build → results → report │ │ │ │ │ │ │ │ │ └─ HTML scorecard │ │ │ └─ aggregate TPM/NOPM/QphH - │ │ └─ build schema + run benchmark (parallel K8s jobs) - │ └─ check config, helm, kubectl, DB connectivity + │ │ └─ build schema + run benchmark (one worker per target) + │ └─ check config, tooling, DB connectivity └─ generate config interactively ``` @@ -84,7 +134,7 @@ init → validate → run --build → results → report | Command | Description | |---------|-------------| -| `version` | Show CLI, Python, helm, kubectl versions | +| `version` | Show CLI, Python, and backend tooling versions | | `init` | Generate config file interactively | | `validate` | Validate config, prerequisites, and connectivity | | `build` | Create benchmark schema on database targets | @@ -93,28 +143,63 @@ init → validate → run --build → results → report | `logs` | View HammerDB output logs | | `results` | Aggregate and display benchmark results | | `report` | Generate self-contained HTML scorecard | -| `clean` | Remove K8s resources and/or database tables | +| `clean` | Remove workers (containers or K8s jobs) and/or database tables | ## Documentation -- [Configuration Reference](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md) — YAML schema, target defaults, examples -- [Usage Guide](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md) — Command reference, results interpretation, troubleshooting -- [Container Images](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONTAINER-IMAGES.md) — Pre-built images, building your own, architecture -- [Migration Guide (v1 to v2)](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/MIGRATION.md) — Upgrading from shell-script version -- [Security](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/SECURITY.md) — Credential handling and network considerations +- [Configuration Reference](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md): YAML schema, target defaults, examples +- [Usage Guide](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md): Command reference, results interpretation, troubleshooting +- [Container Images](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONTAINER-IMAGES.md): Pre-built images, building your own, architecture +- [Migration Guide (v1 to v2)](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/MIGRATION.md): Upgrading from shell-script version +- [Security](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/SECURITY.md): Credential handling and network considerations - [Changelog](https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/CHANGELOG.md) ## Requirements - **Python 3.10+** -- **Helm 3.x** — used to template and deploy Kubernetes Jobs -- **kubectl** — configured with a context that has access to your cluster -- **Kubernetes cluster** — with permissions to create Jobs and Namespaces -- **Database targets** — one or more Oracle or SQL Server instances reachable from the cluster +- **podman or docker**, either one. podman is preferred when both are present. +- **Database targets**, one or more Oracle, SQL Server or PostgreSQL instances reachable from the machine running the workers + +That is the whole list for the default container backend. No cluster, no Helm, +no kubectl. + +### Running on Kubernetes instead + +Set `backend: kubernetes` in your config and add: + +- **Helm 3.x**, used to template and deploy the Jobs +- **kubectl**, configured with a context that has access to your cluster +- **A cluster** with permissions to create Jobs, ConfigMaps and Secrets in your namespace +- Databases reachable **from the cluster**, which is not always the same as reachable from your workstation. `hammerdb-scale validate --from-cluster` checks from where the workers actually run. + +### Platform support + +| Platform | SQL Server | PostgreSQL | Oracle | +|---|---|---|---| +| Linux x86_64 (podman or docker) | Yes | Yes | Yes | +| Kubernetes on x86_64 nodes | Yes | Yes | Yes | +| macOS, Intel | Yes | Yes | Yes | +| macOS, Apple Silicon | Yes | Yes | **No** | +| Windows via WSL2 | Yes | Yes | Yes | + +Oracle support requires Oracle Instant Client, which Oracle publishes for +linux.x64 only. There is no ARM64 build, so Oracle benchmarks cannot run on +Apple Silicon. SQL Server and PostgreSQL are unaffected. + +Oracle also ships as a **separate image you build yourself**, because Oracle's +licence does not permit redistributing Instant Client: + +```bash +git clone https://github.com/PureStorage-OpenConnect/hammerdb-scale +cd hammerdb-scale +./hack/build-images.sh # builds base + Oracle, verifies both +``` + +By building it you accept [Oracle's licence terms](https://www.oracle.com/downloads/licenses/instant-client-lic.html). ### Optional -- [pipx](https://pipx.pypa.io/) — recommended for installing CLI tools in isolated environments: `pipx install hammerdb-scale` +- [pipx](https://pipx.pypa.io/): recommended for installing CLI tools in isolated environments, `pipx install hammerdb-scale` ## Configuration @@ -122,6 +207,7 @@ See the [Configuration Reference](https://github.com/PureStorage-OpenConnect/ham ```yaml name: my-benchmark +backend: podman default_benchmark: tprocc targets: diff --git a/demos/benchmark-payoff.cast b/demos/benchmark-payoff.cast new file mode 100644 index 0000000..ccbd2df --- /dev/null +++ b/demos/benchmark-payoff.cast @@ -0,0 +1,316 @@ +{"version": 2, "width": 120, "height": 35, "timestamp": 1772818908, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}, "title": "HammerDB-Scale: Benchmark Run"} +[0.0, "o", "[user@host hammerdb-scale]$ "] +[0.773444, "o", "h"] +[1.205368, "o", "a"] +[1.4664570000000001, "o", "m"] +[1.6420345, "o", "m"] +[1.77495725, "o", "e"] +[1.886626625, "o", "r"] +[1.9875018125, "o", "d"] +[2.0830429062499998, "o", "b"] +[2.176038953125, "o", "-"] +[2.2676149765625, "o", "s"] +[2.35853448828125, "o", "c"] +[2.449124244140625, "o", "a"] +[2.5396521220703123, "o", "l"] +[2.6299835610351563, "o", "e"] +[2.720262780517578, "o", " "] +[2.8105843902587893, "o", "r"] +[2.900842695129395, "o", "u"] +[2.991092347564697, "o", "n"] +[3.0813436737823485, "o", " "] +[3.171569836891174, "o", "-"] +[3.2618174184455873, "o", "f"] +[3.3519842092227936, "o", " "] +[3.4421751046113966, "o", "/"] +[3.5324275523056983, "o", "t"] +[3.622720276152849, "o", "m"] +[3.7130236380764243, "o", "p"] +[3.803228819038212, "o", "/"] +[3.893461409519106, "o", "m"] +[3.983700704759553, "o", "s"] +[4.073934352379776, "o", "s"] +[4.164072676189888, "o", "q"] +[4.254265338094944, "o", "l"] +[4.344459169047472, "o", "-"] +[4.434726084523736, "o", "2"] +[4.525014042261867, "o", "-"] +[4.615171521130934, "o", "d"] +[4.705403760565467, "o", "e"] +[4.795704380282734, "o", "m"] +[4.885933690141367, "o", "o"] +[4.976157845070683, "o", "."] +[5.066414922535342, "o", "y"] +[5.156721461267671, "o", "a"] +[5.247025230633836, "o", "m"] +[5.3372611153169185, "o", "l"] +[5.4274930576584595, "o", " "] +[5.517683528829229, "o", "-"] +[5.607902264414615, "o", "-"] +[5.698161132207307, "o", "w"] +[5.788389066103654, "o", "a"] +[5.878655533051827, "o", "i"] +[5.968905766525913, "o", "t"] +[6.134169883262956, "o", "\r\n"] +[6.551858, "o", "Benchmark: tprocc \u001b[1m(\u001b[0mfrom config default\u001b[1m)\u001b[0m\r\n"] +[6.552388, "o", "\u256d\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 HammerDB-Scale 2.0.1 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\r\n\u2502 mssql-2-tprocc-demo | tprocc | 2 targets | 10 wh \u2502\r\n\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\r\n"] +[6.552877, "o", "\r\nRunning tprocc benchmark \u001b[1m(\u001b[0m\u001b[1;36m4\u001b[0m virtual users, \u001b[1;36m1\u001b[0m min rampup + \u001b[1;36m1\u001b[0m min test\u001b[1m)\u001b[0m\r\n"] +[6.553148, "o", "Expected completion: ~\u001b[1;36m2\u001b[0m minutes\r\n\r\n"] +[6.553352, "o", "Deploying jobs\u001b[33m...\u001b[0m\r\n"] +[6.994537, "o", " \u001b[32m+\u001b[0m hdb-run-\u001b[1;36m00\u001b[0m-a4226c51 \u001b[1m(\u001b[0msql-bench-\u001b[1;36m01\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[6.995501, "o", " \u001b[32m+\u001b[0m hdb-run-\u001b[1;36m01\u001b[0m-a4226c51 \u001b[1m(\u001b[0msql-bench-\u001b[1;36m02\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[6.996495, "o", "\r\n\u001b[1;36m2\u001b[0m benchmark jobs deployed to namespace \u001b[32m'hammerdb'\u001b[0m.\r\n\r\n"] +[7.096495, "o", "When complete: hammerdb-scale results --benchmark tprocc --id mssql-\u001b[1;36m2\u001b[0m-tprocc-demo-\u001b[1;36m20260306\u001b[0m-\u001b[1;36m1041\u001b[0m\r\n"] +[7.146495, "o", "Generate report: hammerdb-scale report --id mssql-\u001b[1;36m2\u001b[0m-tprocc-demo-\u001b[1;36m20260306\u001b[0m-\u001b[1;36m1041\u001b[0m\r\n"] +[8.196495, "o", " \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m/\u001b[1;36m2\u001b[0m\u001b[1m]\u001b[0m completed, \u001b[1;36m0\u001b[0m failed\r"] +[10.196495, "o", " \u001b[1m[\u001b[0m\u001b[1;36m0\u001b[0m/\u001b[1;36m2\u001b[0m\u001b[1m]\u001b[0m completed, \u001b[1;36m0\u001b[0m failed\r"] +[12.196495, "o", "\r\n"] +[14.196495, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m Completed\r\n"] +[14.197242, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m Completed\r\n"] +[14.241763, "o", "[user@host hammerdb-scale]$ "] +[17.29297, "o", "h"] +[17.383206, "o", "a"] +[17.473288, "o", "m"] +[17.563645, "o", "m"] +[17.653799, "o", "e"] +[17.743942, "o", "r"] +[17.83412, "o", "d"] +[17.924416, "o", "b"] +[18.014587, "o", "-"] +[18.104788, "o", "s"] +[18.194969, "o", "c"] +[18.28521, "o", "a"] +[18.375411, "o", "l"] +[18.465865, "o", "e"] +[18.556117, "o", " "] +[18.64648, "o", "r"] +[18.736661, "o", "e"] +[18.826672, "o", "s"] +[18.917325, "o", "u"] +[19.007542, "o", "l"] +[19.097723, "o", "t"] +[19.187947, "o", "s"] +[19.278225, "o", " "] +[19.368364, "o", "-"] +[19.458624, "o", "f"] +[19.548881, "o", " "] +[19.639111, "o", "/"] +[19.729383, "o", "t"] +[19.819598, "o", "m"] +[19.909955, "o", "p"] +[20.000234, "o", "/"] +[20.090403, "o", "m"] +[20.180553, "o", "s"] +[20.270948, "o", "s"] +[20.36114, "o", "q"] +[20.451287, "o", "l"] +[20.541563, "o", "-"] +[20.631716, "o", "2"] +[20.721895, "o", "-"] +[20.812105, "o", "d"] +[20.90239, "o", "e"] +[20.992579, "o", "m"] +[21.082936, "o", "o"] +[21.173185, "o", "."] +[21.263463, "o", "y"] +[21.353775, "o", "a"] +[21.444021, "o", "m"] +[21.534297, "o", "l"] +[21.774713, "o", "\r\n"] +[22.03103, "o", "Benchmark: tprocc \u001b[1m(\u001b[0mfrom config default\u001b[1m)\u001b[0m\r\n"] +[22.74548, "o", "\u001b[3m Results: mssql-2-tprocc-demo-20260306-1041 \u001b[0m\r\n\u250f\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2533\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2513\r\n\u2503\u001b[1m \u001b[0m\u001b[1m#\u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mTarget \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mHost \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mStatus \u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1mDuration\u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1m TPM\u001b[0m\u001b[1m \u001b[0m\u2503\u001b[1m \u001b[0m\u001b[1m NOPM\u001b[0m\u001b[1m \u001b[0m\u2503\r\n\u2521\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2547\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2501\u2529\r\n\u2502\u001b[2m \u001b[0m\u001b[2m0\u001b[0m\u001b[2m \u001b[0m\u2502 sql-bench-01 \u2502 sql-bench-01.soln.local \u2502 \u001b[32mcompleted\u001b[0m \u2502 2m 08s \u2502 185,918 \u2502 79,941 \u2502\r\n\u2502\u001b[2m \u001b[0m\u001b[2m1\u001b[0m\u001b[2m \u001b[0m\u2502 sql-bench-02 \u2502 sql-bench-02.soln.local \u2502 \u001b[32mcompleted\u001b[0m \u2502 2m 09s \u2502 173,137 \u2502 74,434 \u2502\r\n\u2514\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2534\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2518\r\n"] +[22.745803, "o", "\r\nTotal TPM: \u001b[1;36m359\u001b[0m,\u001b[1;36m055\u001b[0m\r\n"] +[22.746035, "o", "Total NOPM: \u001b[1;36m154\u001b[0m,\u001b[1;36m375\u001b[0m\r\n"] +[22.746297, "o", "\r\nResults saved to .\u001b[35m/results/mssql-2-tprocc-demo-20260306-1041/\u001b[0m\r\n"] +[22.746609, "o", "Generate report: hammerdb-scale report --id mssql-\u001b[1;36m2\u001b[0m-tprocc-demo-\u001b[1;36m20260306\u001b[0m-\u001b[1;36m1041\u001b[0m\r\n"] +[22.789865, "o", "[user@host hammerdb-scale]$ "] +[25.840797, "o", "h"] +[25.931068, "o", "a"] +[26.021267, "o", "m"] +[26.111534, "o", "m"] +[26.201895, "o", "e"] +[26.292014, "o", "r"] +[26.382322, "o", "d"] +[26.472569, "o", "b"] +[26.562774, "o", "-"] +[26.653208, "o", "s"] +[26.743211, "o", "c"] +[26.833903, "o", "a"] +[26.923563, "o", "l"] +[27.014114, "o", "e"] +[27.104305, "o", " "] +[27.194448, "o", "r"] +[27.284714, "o", "e"] +[27.375037, "o", "p"] +[27.465175, "o", "o"] +[27.555363, "o", "r"] +[27.645678, "o", "t"] +[27.735816, "o", " "] +[27.826016, "o", "-"] +[27.916242, "o", "f"] +[28.006453, "o", " "] +[28.096695, "o", "/"] +[28.186982, "o", "t"] +[28.277168, "o", "m"] +[28.367351, "o", "p"] +[28.457596, "o", "/"] +[28.547748, "o", "m"] +[28.63793, "o", "s"] +[28.728211, "o", "s"] +[28.818574, "o", "q"] +[28.908664, "o", "l"] +[28.998872, "o", "-"] +[29.089067, "o", "2"] +[29.179251, "o", "-"] +[29.269372, "o", "d"] +[29.359572, "o", "e"] +[29.449723, "o", "m"] +[29.53995, "o", "o"] +[29.630197, "o", "."] +[29.720438, "o", "y"] +[29.810653, "o", "a"] +[29.900901, "o", "m"] +[29.991118, "o", "l"] +[30.08144, "o", " "] +[30.171701, "o", "-"] +[30.261925, "o", "o"] +[30.352179, "o", " "] +[30.442368, "o", "/"] +[30.532674, "o", "t"] +[30.622899, "o", "m"] +[30.71322, "o", "p"] +[30.803387, "o", "/"] +[30.893643, "o", "s"] +[30.983926, "o", "c"] +[31.074174, "o", "o"] +[31.16431, "o", "r"] +[31.254589, "o", "e"] +[31.344877, "o", "c"] +[31.435085, "o", "a"] +[31.525148, "o", "r"] +[31.615472, "o", "d"] +[31.705672, "o", "."] +[31.795795, "o", "h"] +[31.886057, "o", "t"] +[31.976266, "o", "m"] +[32.066518, "o", "l"] +[32.306889, "o", "\r\n"] +[32.795643, "o", "\u001b[32mReport generated: \u001b[0m\u001b[32m/tmp/\u001b[0m\u001b[32mscorecard.html\u001b[0m\r\n"] +[32.837703, "o", "[user@host hammerdb-scale]$ "] +[35.889047, "o", "h"] +[35.979203, "o", "a"] +[36.069495, "o", "m"] +[36.159823, "o", "m"] +[36.250123, "o", "e"] +[36.340341, "o", "r"] +[36.430594, "o", "d"] +[36.520892, "o", "b"] +[36.611188, "o", "-"] +[36.701457, "o", "s"] +[36.791688, "o", "c"] +[36.881998, "o", "a"] +[36.972259, "o", "l"] +[37.062503, "o", "e"] +[37.152758, "o", " "] +[37.242962, "o", "c"] +[37.333317, "o", "l"] +[37.423395, "o", "e"] +[37.513652, "o", "a"] +[37.603928, "o", "n"] +[37.69422, "o", " "] +[37.784378, "o", "-"] +[37.874619, "o", "f"] +[37.964858, "o", " "] +[38.055182, "o", "/"] +[38.145291, "o", "t"] +[38.235535, "o", "m"] +[38.325765, "o", "p"] +[38.416074, "o", "/"] +[38.506286, "o", "m"] +[38.596509, "o", "s"] +[38.686571, "o", "s"] +[38.776873, "o", "q"] +[38.867357, "o", "l"] +[38.957535, "o", "-"] +[39.047728, "o", "2"] +[39.138013, "o", "-"] +[39.22824, "o", "d"] +[39.318423, "o", "e"] +[39.408646, "o", "m"] +[39.498936, "o", "o"] +[39.589148, "o", "."] +[39.679406, "o", "y"] +[39.769685, "o", "a"] +[39.859948, "o", "m"] +[39.950201, "o", "l"] +[40.040495, "o", " "] +[40.130756, "o", "-"] +[40.220935, "o", "-"] +[40.311199, "o", "r"] +[40.401494, "o", "e"] +[40.491531, "o", "s"] +[40.581963, "o", "o"] +[40.672195, "o", "u"] +[40.762398, "o", "r"] +[40.852599, "o", "c"] +[40.942824, "o", "e"] +[41.033057, "o", "s"] +[41.123212, "o", " "] +[41.213511, "o", "-"] +[41.303672, "o", "-"] +[41.393921, "o", "d"] +[41.484146, "o", "a"] +[41.57436, "o", "t"] +[41.66455, "o", "a"] +[41.754786, "o", "b"] +[41.845158, "o", "a"] +[41.935362, "o", "s"] +[42.025546, "o", "e"] +[42.115678, "o", " "] +[42.205941, "o", "-"] +[42.296206, "o", "-"] +[42.386358, "o", "b"] +[42.476567, "o", "e"] +[42.566729, "o", "n"] +[42.657083, "o", "c"] +[42.747186, "o", "h"] +[42.837449, "o", "m"] +[42.927773, "o", "a"] +[43.01802, "o", "r"] +[43.108249, "o", "k"] +[43.198455, "o", " "] +[43.28873, "o", "t"] +[43.378961, "o", "p"] +[43.469192, "o", "r"] +[43.559501, "o", "o"] +[43.64972, "o", "c"] +[43.74003, "o", "c"] +[43.83011, "o", " "] +[43.920446, "o", "-"] +[44.010722, "o", "-"] +[44.100929, "o", "e \r"] +[44.191173, "o", "v"] +[44.281356, "o", "e"] +[44.37164, "o", "r"] +[44.461783, "o", "y"] +[44.552085, "o", "t"] +[44.642144, "o", "h"] +[44.732484, "o", "i"] +[44.822555, "o", "n"] +[44.912869, "o", "g"] +[45.003122, "o", " "] +[45.09313, "o", "-"] +[45.183433, "o", "-"] +[45.273544, "o", "f"] +[45.363704, "o", "o"] +[45.453895, "o", "r"] +[45.54394, "o", "c"] +[45.634269, "o", "e"] +[45.874722, "o", "\r\n"] +[46.211599, "o", "\r\nFound \u001b[1;36m1\u001b[0m \u001b[1;35mrelease\u001b[0m\u001b[1m(\u001b[0ms\u001b[1m)\u001b[0m:\r\n"] +[46.212359, "o", " hdb-run-a4226c51 \u001b[1m(\u001b[0mdeployed\u001b[1m)\u001b[0m\r\n"] +[46.372614, "o", " \u001b[32m+\u001b[0m Uninstalled hdb-run-a4226c51\r\n"] +[46.373531, "o", "\r\nK8s resources cleaned. Local results preserved in .\u001b[35m/results/\u001b[0m\r\n"] +[46.632827, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m \u001b[1;36m9\u001b[0m tables dropped\r\n"] +[46.865741, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m \u001b[1;36m9\u001b[0m tables dropped\r\n"] +[46.911674, "o", "[user@host hammerdb-scale]$ "] diff --git a/demos/getting-started-synthetic.cast b/demos/getting-started-synthetic.cast new file mode 100644 index 0000000..0e55f8b --- /dev/null +++ b/demos/getting-started-synthetic.cast @@ -0,0 +1,403 @@ +{"version": 2, "width": 120, "height": 35, "timestamp": 1741223100, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/bash", "TERM": "xterm-256color"}, "title": "HammerDB-Scale 2.0: Getting Started"} +[0.03, "o", "\u001b[0m[user@host hammerdb-scale]$ "] +[0.223692, "o", "p"] +[0.263149, "o", "i"] +[0.290169, "o", "p"] +[0.369159, "o", " "] +[0.405328, "o", "i"] +[0.425753, "o", "n"] +[0.451866, "o", "s"] +[0.493443, "o", "t"] +[0.513523, "o", "a"] +[0.551191, "o", "l"] +[0.595659, "o", "l"] +[0.670898, "o", " "] +[0.695462, "o", "h"] +[0.723339, "o", "a"] +[0.745327, "o", "m"] +[0.785212, "o", "m"] +[0.810223, "o", "e"] +[0.833264, "o", "r"] +[0.872068, "o", "d"] +[0.910923, "o", "b"] +[0.954487, "o", "-"] +[0.980996, "o", "s"] +[1.016272, "o", "c"] +[1.035517, "o", "a"] +[1.06374, "o", "l"] +[1.087896, "o", "e"] +[1.160447, "o", "\r\n"] +[1.390447, "o", "Requirement already satisfied: hammerdb-scale in /usr/local/lib/python3.11/site-packages (2.0.2)\r\n"] +[1.420447, "o", "Requirement already satisfied: typer>=0.12.0 in /usr/local/lib/python3.11/site-packages (0.15.2)\r\n"] +[1.450447, "o", "Requirement already satisfied: pydantic>=2.5.0 in /usr/local/lib/python3.11/site-packages (2.10.6)\r\n"] +[1.480447, "o", "Requirement already satisfied: pyyaml>=6.0 in /usr/local/lib/python3.11/site-packages (6.0.2)\r\n"] +[1.510447, "o", "Requirement already satisfied: rich>=13.7.0 in /usr/local/lib/python3.11/site-packages (13.9.4)\r\n"] +[1.540447, "o", "Requirement already satisfied: oracledb>=2.0.0 in /usr/local/lib/python3.11/site-packages (2.5.1)\r\n"] +[1.570447, "o", "Requirement already satisfied: pymssql>=2.2.0 in /usr/local/lib/python3.11/site-packages (2.3.2)\r\n"] +[1.750447, "o", "\u001b[0m[user@host hammerdb-scale]$ "] +[2.095452, "o", "h"] +[2.118178, "o", "a"] +[2.154752, "o", "m"] +[2.243602, "o", "m"] +[2.266598, "o", "e"] +[2.309372, "o", "r"] +[2.333945, "o", "d"] +[2.372353, "o", "b"] +[2.407881, "o", "-"] +[2.426806, "o", "s"] +[2.453797, "o", "c"] +[2.485035, "o", "a"] +[2.519567, "o", "l"] +[2.5601, "o", "e"] +[2.646476, "o", " "] +[2.678098, "o", "i"] +[2.705412, "o", "n"] +[2.735554, "o", "i"] +[2.768682, "o", "t"] +[2.859574, "o", " "] +[2.903314, "o", "-"] +[2.943017, "o", "i"] +[3.007728, "o", " "] +[3.049018, "o", "-"] +[3.092966, "o", "o"] +[3.2072, "o", " "] +[3.238501, "o", "/"] +[3.279796, "o", "t"] +[3.340067, "o", "m"] +[3.3751, "o", "p"] +[3.401697, "o", "/"] +[3.434529, "o", "h"] +[3.465218, "o", "a"] +[3.498779, "o", "m"] +[3.532696, "o", "m"] +[3.55839, "o", "e"] +[3.577485, "o", "r"] +[3.620456, "o", "d"] +[3.653793, "o", "b"] +[3.688258, "o", "-"] +[3.712292, "o", "s"] +[3.738006, "o", "c"] +[3.760946, "o", "a"] +[3.783046, "o", "l"] +[3.824323, "o", "e"] +[3.856012, "o", "-"] +[3.893186, "o", "d"] +[3.918385, "o", "e"] +[3.93753, "o", "m"] +[3.969954, "o", "o"] +[4.013281, "o", "."] +[4.038129, "o", "y"] +[4.082951, "o", "a"] +[4.114383, "o", "m"] +[4.155436, "o", "l"] +[4.232572, "o", " "] +[4.264993, "o", "-"] +[4.295824, "o", "-"] +[4.337772, "o", "f"] +[4.369259, "o", "o"] +[4.388307, "o", "r"] +[4.408063, "o", "c"] +[4.447167, "o", "e"] +[4.507558, "o", "\r\n"] +[4.922558, "o", "\r\n"] +[4.937558, "o", "\u001b[34m\u256d\u2500\u001b[0m hammerdb-scale 2.0.2 \u001b[34m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[4.952558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[4.967558, "o", "\u001b[34m\u2502\u001b[0m \u001b[1mHammerDB-Scale Configuration Wizard\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[4.982558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[4.997558, "o", "\u001b[34m\u2502\u001b[0m This wizard will guide you through creating a benchmark \u001b[34m\u2502\u001b[0m\r\n"] +[5.012558, "o", "\u001b[34m\u2502\u001b[0m configuration file step by step. \u001b[34m\u2502\u001b[0m\r\n"] +[5.027558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[5.042558, "o", "\u001b[34m\u2502\u001b[0m \u001b[2mPress Ctrl+C at any time to cancel.\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[5.057558, "o", "\u001b[34m\u2502\u001b[0m \u001b[34m\u2502\u001b[0m\r\n"] +[5.072558, "o", "\u001b[34m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[6.087558, "o", "\r\n"] +[6.102558, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 1 of 6 \u2014 Deployment\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[6.117558, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[6.132558, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mGive this benchmark a name.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[6.147558, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[6.162558, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[6.177558, "o", "\r\n"] +[6.197558, "o", "Deployment name: "] +[6.786073, "o", "m"] +[6.862991, "o", "s"] +[6.908683, "o", "s"] +[6.969074, "o", "q"] +[7.080079, "o", "l"] +[7.14452, "o", "-"] +[7.237919, "o", "2"] +[7.328671, "o", "-"] +[7.406783, "o", "t"] +[7.471374, "o", "p"] +[7.560689, "o", "r"] +[7.615932, "o", "o"] +[7.70818, "o", "c"] +[7.797355, "o", "c"] +[7.890943, "o", "-"] +[7.948239, "o", "d"] +[8.028665, "o", "e"] +[8.080257, "o", "m"] +[8.153042, "o", "o"] +[8.212542, "o", "\r\n"] +[8.477542, "o", "\r\n"] +[8.492542, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 2 of 6 \u2014 Database & Benchmark\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[8.507542, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[8.522542, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mSelect the database engine and TPC benchmark type.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[8.537542, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[8.552542, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[8.867542, "o", "\r\n"] +[8.927542, "o", " \u001b[1m1.\u001b[0m Oracle\r\n"] +[8.987542, "o", " \u001b[1m2.\u001b[0m Microsoft SQL Server\r\n"] +[9.002542, "o", "\r\n"] +[9.022542, "o", "Database type \u001b[1;35m[1/2]\u001b[0m: "] +[9.660315, "o", "2"] +[9.714554, "o", "\r\n"] +[10.079554, "o", "\r\n"] +[10.139554, "o", " \u001b[1m1.\u001b[0m TPC-C (OLTP transactional)\r\n"] +[10.199554, "o", " \u001b[1m2.\u001b[0m TPC-H (OLAP analytical)\r\n"] +[10.214554, "o", "\r\n"] +[10.234554, "o", "Benchmark \u001b[1;35m[1/2]\u001b[0m: "] +[10.801824, "o", "1"] +[10.848634, "o", "\r\n"] +[11.213634, "o", "\r\n"] +[11.228634, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 3 of 6 \u2014 Database Targets\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[11.243634, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[11.258634, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mDefine the database hosts to benchmark against.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[11.273634, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[11.288634, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[11.603634, "o", "\r\n"] +[11.623634, "o", "Number of database targets \u001b[1;35m(1)\u001b[0m: "] +[12.174596, "o", "2"] +[12.245777, "o", "\r\n"] +[12.560777, "o", "\n \u001b[1m\u001b[36mTarget 1 of 2\u001b[0m\r\n"] +[12.580777, "o", " Name \u001b[1;35m(db-01)\u001b[0m: "] +[13.154687, "o", "s"] +[13.211971, "o", "q"] +[13.288086, "o", "l"] +[13.379469, "o", "-"] +[13.455904, "o", "b"] +[13.524189, "o", "e"] +[13.582623, "o", "n"] +[13.672774, "o", "c"] +[13.731954, "o", "h"] +[13.81285, "o", "-"] +[13.866637, "o", "0"] +[13.925804, "o", "1"] +[13.999524, "o", "\r\n"] +[14.019524, "o", " Hostname or IP: "] +[14.611138, "o", "s"] +[14.675057, "o", "q"] +[14.712717, "o", "l"] +[14.779931, "o", "-"] +[14.836058, "o", "b"] +[14.89024, "o", "e"] +[14.941287, "o", "n"] +[14.988901, "o", "c"] +[15.053212, "o", "h"] +[15.119398, "o", "-"] +[15.187126, "o", "0"] +[15.250928, "o", "1"] +[15.318975, "o", "."] +[15.374746, "o", "s"] +[15.412954, "o", "o"] +[15.448652, "o", "l"] +[15.48845, "o", "n"] +[15.54579, "o", "."] +[15.590073, "o", "l"] +[15.656989, "o", "o"] +[15.713194, "o", "c"] +[15.755966, "o", "a"] +[15.821408, "o", "l"] +[15.861606, "o", "\r\n"] +[16.126606, "o", "\n \u001b[1m\u001b[36mTarget 2 of 2\u001b[0m\r\n"] +[16.146606, "o", " Name \u001b[1;35m(db-02)\u001b[0m: "] +[16.781571, "o", "s"] +[16.872666, "o", "q"] +[16.950926, "o", "l"] +[17.023206, "o", "-"] +[17.101768, "o", "b"] +[17.166159, "o", "e"] +[17.313978, "o", "n"] +[17.372762, "o", "c"] +[17.45197, "o", "h"] +[17.533333, "o", "-"] +[17.608461, "o", "0"] +[17.667301, "o", "2"] +[17.740063, "o", "\r\n"] +[17.760063, "o", " Hostname or IP: "] +[18.36035, "o", "s"] +[18.423445, "o", "q"] +[18.469282, "o", "l"] +[18.539229, "o", "-"] +[18.589069, "o", "b"] +[18.632966, "o", "e"] +[18.675911, "o", "n"] +[18.72383, "o", "c"] +[18.783463, "o", "h"] +[18.834984, "o", "-"] +[18.876315, "o", "0"] +[18.920136, "o", "2"] +[18.977847, "o", "."] +[19.029097, "o", "s"] +[19.079821, "o", "o"] +[19.144836, "o", "l"] +[19.180137, "o", "n"] +[19.236834, "o", "."] +[19.296139, "o", "l"] +[19.359145, "o", "o"] +[19.402386, "o", "c"] +[19.467941, "o", "a"] +[19.536977, "o", "l"] +[19.587256, "o", "\r\n"] +[19.852256, "o", "\r\n"] +[19.867256, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 4 of 6 \u2014 Credentials\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[19.882256, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[19.897256, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mDatabase authentication shared by all targets.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[19.912256, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[19.927256, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[20.242256, "o", "\r\n"] +[20.262256, "o", "Database username \u001b[1;35m(sa)\u001b[0m: "] +[20.79401, "o", "\r\n"] +[20.96401, "o", "Database password: "] +[21.483885, "o", ""] +[21.524908, "o", ""] +[21.563229, "o", ""] +[21.606008, "o", ""] +[21.660163, "o", ""] +[21.706809, "o", ""] +[21.740665, "o", ""] +[21.790951, "o", ""] +[21.845216, "o", "\r\n"] +[22.010216, "o", "\r\n"] +[22.025216, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 5 of 6 \u2014 Benchmark Parameters\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[22.040216, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[22.055216, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mConfigure the benchmark workload.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[22.070216, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[22.085216, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[22.400216, "o", "\r\n"] +[22.460216, "o", " \u001b[2mRule of thumb: 100 warehouses ~ 10 GB per target.\u001b[0m\r\n"] +[22.475216, "o", "\r\n"] +[22.495216, "o", "Warehouses per target \u001b[1;35m(100)\u001b[0m: "] +[23.059817, "o", "1"] +[23.104997, "o", "0"] +[23.245894, "o", "0"] +[23.313562, "o", "\r\n"] +[23.478562, "o", "\r\n"] +[23.493562, "o", "\u001b[36m\u256d\u2500\u001b[0m \u001b[1mStep 6 of 6 \u2014 Infrastructure\u001b[0m \u001b[36m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[23.508562, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[23.523562, "o", "\u001b[36m\u2502\u001b[0m \u001b[2mKubernetes and storage settings.\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[23.538562, "o", "\u001b[36m\u2502\u001b[0m \u001b[36m\u2502\u001b[0m\r\n"] +[23.553562, "o", "\u001b[36m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[23.868562, "o", "\r\n"] +[23.888562, "o", "Kubernetes namespace \u001b[1;35m(hammerdb)\u001b[0m: "] +[24.38804, "o", "\r\n"] +[24.55304, "o", "\r\n"] +[24.57304, "o", "Enable Pure Storage metrics collection? \u001b[1;35m[y/n]\u001b[0m (y): "] +[24.987898, "o", "\r\n"] +[25.152898, "o", "\r\n"] +[25.172898, "o", "Configure advanced options (VUs, rampup, duration, resources)? \u001b[1;35m[y/n]\u001b[0m (n): "] +[25.618553, "o", "\r\n"] +[25.933553, "o", "\r\n"] +[25.948553, "o", "\u001b[32m\u256d\u2500\u001b[0m \u001b[1mConfiguration Summary\u001b[0m \u001b[32m\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256e\u001b[0m\r\n"] +[25.963553, "o", "\u001b[32m\u2502\u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[25.978553, "o", "\u001b[32m\u2502\u001b[0m \u001b[1m\u001b[36mSetting \u001b[0m \u001b[1mValue \u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[25.993553, "o", "\u001b[32m\u2502\u001b[0m \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500 \u001b[32m\u2502\u001b[0m\r\n"] +[26.008553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mDeployment name \u001b[0m mssql-2-tprocc-demo \u001b[32m\u2502\u001b[0m\r\n"] +[26.023553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mDatabase type \u001b[0m SQL Server \u001b[32m\u2502\u001b[0m\r\n"] +[26.038553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mBenchmark \u001b[0m TPC-C (OLTP) \u001b[32m\u2502\u001b[0m\r\n"] +[26.053553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mTargets \u001b[0m 2 \u001b[32m\u2502\u001b[0m\r\n"] +[26.068553, "o", "\u001b[32m\u2502\u001b[0m \u001b[2m sql-bench-01 \u001b[0m \u001b[2msql-bench-01.soln.local \u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[26.083553, "o", "\u001b[32m\u2502\u001b[0m \u001b[2m sql-bench-02 \u001b[0m \u001b[2msql-bench-02.soln.local \u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[26.098553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mUsername \u001b[0m sa \u001b[32m\u2502\u001b[0m\r\n"] +[26.113553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mPassword \u001b[0m ******** \u001b[32m\u2502\u001b[0m\r\n"] +[26.128553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mWarehouses \u001b[0m 100 \u001b[32m\u2502\u001b[0m\r\n"] +[26.143553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mNamespace \u001b[0m hammerdb \u001b[32m\u2502\u001b[0m\r\n"] +[26.158553, "o", "\u001b[32m\u2502\u001b[0m \u001b[36mPure Storage \u001b[0m Disabled \u001b[32m\u2502\u001b[0m\r\n"] +[26.173553, "o", "\u001b[32m\u2502\u001b[0m \u001b[32m\u2502\u001b[0m\r\n"] +[26.188553, "o", "\u001b[32m\u2570\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u256f\u001b[0m\r\n"] +[29.703553, "o", "\r\n"] +[29.723553, "o", "Write configuration? \u001b[1;35m[y/n]\u001b[0m (y): "] +[30.191687, "o", "\r\n"] +[30.406687, "o", "\n\u001b[32mConfig written to /tmp/hammerdb-scale-demo.yaml\u001b[0m\r\n"] +[30.421687, "o", "\r\n"] +[30.436687, "o", "Next steps:\r\n"] +[30.451687, "o", " hammerdb-scale validate Check config and prerequisites\r\n"] +[30.466687, "o", " hammerdb-scale run --build Build schemas and run benchmark\r\n"] +[30.481687, "o", " hammerdb-scale run Run benchmark \u001b[1m(\u001b[0mif schemas already built\u001b[1m)\u001b[0m\r\n"] +[31.311687, "o", "\u001b[0m[user@host hammerdb-scale]$ "] +[31.648907, "o", "h"] +[31.711812, "o", "a"] +[31.779217, "o", "m"] +[31.823577, "o", "m"] +[31.873947, "o", "e"] +[31.911268, "o", "r"] +[31.949216, "o", "d"] +[31.997895, "o", "b"] +[32.041726, "o", "-"] +[32.179392, "o", "s"] +[32.227152, "o", "c"] +[32.354005, "o", "a"] +[32.415179, "o", "l"] +[32.460257, "o", "e"] +[32.562903, "o", " "] +[32.617242, "o", "v"] +[32.684781, "o", "a"] +[32.753734, "o", "l"] +[32.798482, "o", "i"] +[32.848069, "o", "d"] +[32.889452, "o", "a"] +[32.936512, "o", "t"] +[32.99598, "o", "e"] +[33.103973, "o", " "] +[33.141533, "o", "-"] +[33.187803, "o", "f"] +[33.296351, "o", " "] +[33.334078, "o", "/"] +[33.382415, "o", "t"] +[33.446766, "o", "m"] +[33.509954, "o", "p"] +[33.552508, "o", "/"] +[33.588011, "o", "h"] +[33.65561, "o", "a"] +[33.700206, "o", "m"] +[33.749407, "o", "m"] +[33.797043, "o", "e"] +[33.852782, "o", "r"] +[33.897348, "o", "d"] +[33.938288, "o", "b"] +[34.005639, "o", "-"] +[34.057092, "o", "s"] +[34.123949, "o", "c"] +[34.178876, "o", "a"] +[34.219695, "o", "l"] +[34.261353, "o", "e"] +[34.296796, "o", "-"] +[34.421445, "o", "d"] +[34.569403, "o", "e"] +[34.634968, "o", "m"] +[34.699489, "o", "o"] +[34.764399, "o", "."] +[34.831788, "o", "y"] +[34.90072, "o", "a"] +[34.961273, "o", "m"] +[35.002848, "o", "l"] +[35.051201, "o", "\r\n"] +[35.431201, "o", "Benchmark: tprocc \u001b[1m(\u001b[0mfrom config default\u001b[1m)\u001b[0m\r\n"] +[35.661201, "o", "\nValidating configuration\u001b[33m...\u001b[0m\r\n"] +[35.891201, "o", " \u001b[32m+\u001b[0m YAML syntax valid\r\n"] +[35.971201, "o", " \u001b[32m+\u001b[0m Schema validates \u001b[1m(\u001b[0mall required fields present\u001b[1m)\u001b[0m\r\n"] +[36.051201, "o", " \u001b[32m+\u001b[0m \u001b[1;36m2\u001b[0m targets configured \u001b[1m(\u001b[0mtype: mssql\u001b[1m)\u001b[0m\r\n"] +[36.131201, "o", " \u001b[32m+\u001b[0m Benchmark settings valid \u001b[1m(\u001b[0mtprocc, \u001b[1;36m100\u001b[0m warehouses\u001b[1m)\u001b[0m\r\n"] +[36.211201, "o", " \u001b[32m+\u001b[0m Default benchmark: tprocc\r\n"] +[36.441201, "o", "\nChecking prerequisites\u001b[33m...\u001b[0m\r\n"] +[36.691201, "o", " \u001b[32m+\u001b[0m helm found \u001b[1m(\u001b[0mv3.\u001b[1;36m19.4\u001b[0m+g7cfb6e4\u001b[1m)\u001b[0m\r\n"] +[36.771201, "o", " \u001b[32m+\u001b[0m kubectl found \u001b[1m(\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[36.851201, "o", " \u001b[32m+\u001b[0m Current context: admin\r\n"] +[37.081201, "o", "\nChecking Kubernetes access\u001b[33m...\u001b[0m\r\n"] +[37.331201, "o", " \u001b[33m!\u001b[0m Namespace \u001b[32m'hammerdb'\u001b[0m does not exist. It will be created during deployment.\r\n"] +[37.411201, "o", " \u001b[32m+\u001b[0m Can create Jobs in namespace\r\n"] +[37.491201, "o", " \u001b[32m+\u001b[0m Can create ConfigMaps in namespace\r\n"] +[37.721201, "o", "\nChecking database connectivity\u001b[33m...\u001b[0m\r\n"] +[38.521201, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m sql-bench-\u001b[1;36m01.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[38.871201, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m sql-bench-\u001b[1;36m02.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[39.251201, "o", "\nValidation complete: \u001b[1;36m0\u001b[0m \u001b[1;35mwarning\u001b[0m\u001b[1m(\u001b[0ms\u001b[1m)\u001b[0m, \u001b[1;36m0\u001b[0m errors.\r\n"] +[41.281201, "o", "\u001b[0m[user@host hammerdb-scale]$ "] diff --git a/demos/getting-started.cast b/demos/getting-started.cast new file mode 100644 index 0000000..d7200ac --- /dev/null +++ b/demos/getting-started.cast @@ -0,0 +1,668 @@ +{"version": 2, "width": 100, "height": 30, "timestamp": 1772771692, "idle_time_limit": 2.0, "env": {"SHELL": "/bin/bash", "TERM": null}, "title": "hammerdb-scale: Getting Started"} +[0.066034, "o", "[root@august hammerdb-scale]# "] +[1.11732, "o", "p"] +[1.207601, "o", "i"] +[1.297849, "o", "p"] +[1.387901, "o", " "] +[1.478276, "o", "i"] +[1.568629, "o", "n"] +[1.659002, "o", "s"] +[1.749211, "o", "t"] +[1.839371, "o", "a"] +[1.929528, "o", "l"] +[2.019598, "o", "l"] +[2.109889, "o", " "] +[2.200296, "o", "h"] +[2.290359, "o", "a"] +[2.380679, "o", "m"] +[2.470901, "o", "m"] +[2.561049, "o", "e"] +[2.651182, "o", "r"] +[2.741421, "o", "d"] +[2.831715, "o", "b"] +[2.921993, "o", "-"] +[3.012263, "o", "s"] +[3.102604, "o", "c"] +[3.192811, "o", "a"] +[3.283108, "o", "l"] +[3.373113, "o", "e"] +[3.61359, "o", "\r\n"] +[3.898358, "o", "Requirement already satisfied: hammerdb-scale in /usr/local/lib/python3.11/site-packages (2.0.0)\r\n"] +[3.899483, "o", "Requirement already satisfied: oracledb>=2.0.0 in /usr/local/lib64/python3.11/site-packages (from hammerdb-scale) (3.4.2)\r\n"] +[3.900004, "o", "Requirement already satisfied: pydantic>=2.5.0 in /usr/local/lib/python3.11/site-packages (from hammerdb-scale) (2.12.5)\r\n"] +[3.900524, "o", "Requirement already satisfied: pymssql>=2.2.0 in /usr/local/lib64/python3.11/site-packages (from hammerdb-scale) (2.3.13)\r\n"] +[3.90103, "o", "Requirement already satisfied: pyyaml>=6.0 in /usr/local/lib64/python3.11/site-packages (from hammerdb-scale) (6.0.3)\r\n"] +[3.901499, "o", "Requirement already satisfied: rich>=13.7.0 in /usr/local/lib/python3.11/site-packages (from hammerdb-scale) (14.3.2)\r\n"] +[3.901971, "o", "Requirement already satisfied: typer>=0.12.0 in /usr/local/lib/python3.11/site-packages (from hammerdb-scale) (0.21.2)\r\n"] +[3.903498, "o", "Requirement already satisfied: cryptography>=3.2.1 in /usr/local/lib64/python3.11/site-packages (from oracledb>=2.0.0->hammerdb-scale) (46.0.4)\r\n"] +[3.90394, "o", "Requirement already satisfied: typing_extensions>=4.14.0 in /usr/local/lib/python3.11/site-packages (from oracledb>=2.0.0->hammerdb-scale) (4.15.0)\r\n"] +[3.906208, "o", "Requirement already satisfied: cffi>=2.0.0 in /usr/local/lib64/python3.11/site-packages (from cryptography>=3.2.1->oracledb>=2.0.0->hammerdb-scale) (2.0.0)\r\n"] +[3.908759, "o", "Requirement already satisfied: pycparser in /usr/local/lib/python3.11/site-packages (from cffi>=2.0.0->cryptography>=3.2.1->oracledb>=2.0.0->hammerdb-scale) (3.0)\r\n"] +[3.910673, "o", "Requirement already satisfied: annotated-types>=0.6.0 in /usr/local/lib/python3.11/site-packages (from pydantic>=2.5.0->hammerdb-scale) (0.7.0)\r\n"] +[3.911086, "o", "Requirement already satisfied: pydantic-core==2.41.5 in /usr/local/lib64/python3.11/site-packages (from pydantic>=2.5.0->hammerdb-scale) (2.41.5)\r\n"] +[3.911627, "o", "Requirement already satisfied: typing-inspection>=0.4.2 in /usr/local/lib/python3.11/site-packages (from pydantic>=2.5.0->hammerdb-scale) (0.4.2)\r\n"] +[3.917204, "o", "Requirement already satisfied: markdown-it-py>=2.2.0 in /usr/local/lib/python3.11/site-packages (from rich>=13.7.0->hammerdb-scale) (4.0.0)\r\n"] +[3.917713, "o", "Requirement already satisfied: pygments<3.0.0,>=2.13.0 in /usr/local/lib/python3.11/site-packages (from rich>=13.7.0->hammerdb-scale) (2.19.2)\r\n"] +[3.91945, "o", "Requirement already satisfied: mdurl~=0.1 in /usr/local/lib/python3.11/site-packages (from markdown-it-py>=2.2.0->rich>=13.7.0->hammerdb-scale) (0.1.2)\r\n"] +[3.922536, "o", "Requirement already satisfied: click>=8.0.0 in /usr/local/lib/python3.11/site-packages (from typer>=0.12.0->hammerdb-scale) (8.3.1)\r\n"] +[3.923082, "o", "Requirement already satisfied: annotated-doc>=0.0.2 in /usr/local/lib/python3.11/site-packages (from typer>=0.12.0->hammerdb-scale) (0.0.4)\r\n"] +[3.923484, "o", "Requirement already satisfied: shellingham>=1.3.0 in /usr/local/lib/python3.11/site-packages (from typer>=0.12.0->hammerdb-scale) (1.5.4)\r\n"] +[4.089887, "o", "\u001b[33mWARNING: Running pip as the 'root' user can result in broken permissions and conflicting behaviour with the system package manager, possibly rendering your system unusable. It is recommended to use a virtual environment instead: https://pip.pypa.io/warnings/venv. Use the --root-user-action option if you know what you are doing and want to suppress this warning.\u001b[0m\u001b[33m\r\n\u001b[0m"] +[4.148297, "o", "[root@august hammerdb-scale]# "] +[5.199794, "o", "h"] +[5.289927, "o", "a"] +[5.380122, "o", "m"] +[5.47031, "o", "m"] +[5.560631, "o", "e"] +[5.650858, "o", "r"] +[5.741044, "o", "d"] +[5.831375, "o", "b"] +[5.921485, "o", "-"] +[6.011709, "o", "s"] +[6.102179, "o", "c"] +[6.192469, "o", "a"] +[6.282559, "o", "l"] +[6.372856, "o", "e"] +[6.462926, "o", " "] +[6.553193, "o", "i"] +[6.643521, "o", "n"] +[6.733621, "o", "i"] +[6.824075, "o", "t"] +[6.914132, "o", " "] +[7.004242, "o", "-"] +[7.094427, "o", "i"] +[7.184662, "o", " "] +[7.274849, "o", "-"] +[7.36508, "o", "o"] +[7.455257, "o", " "] +[7.545799, "o", "/"] +[7.635981, "o", "t"] +[7.726281, "o", "m"] +[7.81646, "o", "p"] +[7.90642, "o", "/"] +[7.996698, "o", "h"] +[8.086919, "o", "a"] +[8.177462, "o", "m"] +[8.267631, "o", "m"] +[8.357628, "o", "e"] +[8.447911, "o", "r"] +[8.53838, "o", "d"] +[8.628555, "o", "b"] +[8.718801, "o", "-"] +[8.8091, "o", "s"] +[8.899247, "o", "c"] +[8.989238, "o", "a"] +[9.0798, "o", "l"] +[9.17003, "o", "e"] +[9.259851, "o", "-"] +[9.350102, "o", "d"] +[9.440782, "o", "e"] +[9.531002, "o", "m"] +[9.621246, "o", "o"] +[9.711387, "o", "."] +[9.801543, "o", "y"] +[9.891355, "o", "a"] +[9.982163, "o", "m"] +[10.072015, "o", "l"] +[10.162675, "o", " "] +[10.25278, "o", "-"] +[10.343205, "o", "-"] +[10.433386, "o", "f"] +[10.523681, "o", "o"] +[10.613864, "o", "r"] +[10.704222, "o", "c"] +[10.794523, "o", "e"] +[11.034314, "o", "\r\n"] +[11.27589, "o", "\r\n"] +[11.276827, "o", "\u001b[34m╭─\u001b[0m\u001b[34m─────────────────────────────────────\u001b[0m\u001b[34m hammerdb-scale 2.0.1 \u001b[0m\u001b[34m─────────────────────────────────────\u001b[0m\u001b[34m─╮\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[1mHammerDB-Scale Configuration Wizard\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m This wizard will guide you through creating a benchmark \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m configuration file step by step. \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[2mPress Ctrl+C at any time to cancel.\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m│\u001b[0m \u001b[34m│\u001b[0m\r\n\u001b[34m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[11.277088, "o", "\r\n"] +[11.277829, "o", "\u001b[36m╭─\u001b[0m\u001b[36m───────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 1 of 6 — Deployment\u001b[0m\u001b[36m \u001b[0m\u001b[36m───────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mGive this benchmark a name.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[11.278017, "o", "\r\n"] +[11.2782, "o", "Deployment name: "] +[12.085456, "o", "m"] +[12.175532, "o", "s"] +[12.265886, "o", "s"] +[12.355915, "o", "q"] +[12.446203, "o", "l"] +[12.536438, "o", "-"] +[12.626781, "o", "8"] +[12.717211, "o", "-"] +[12.807633, "o", "t"] +[12.897657, "o", "p"] +[12.98796, "o", "r"] +[13.078182, "o", "o"] +[13.168447, "o", "c"] +[13.258656, "o", "c"] +[13.34888, "o", "-"] +[13.439145, "o", "d"] +[13.529485, "o", "e"] +[13.619728, "o", "m"] +[13.709972, "o", "o"] +[13.950315, "o", "\r\n"] +[13.950824, "o", "\r\n"] +[13.952528, "o", "\u001b[36m╭─\u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 2 of 6 — Database & Benchmark\u001b[0m\u001b[36m \u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mSelect the database engine and TPC benchmark type.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[13.95274, "o", "\r\n"] +[13.95894, "o", " \u001b[1;36m1\u001b[0m\u001b[1m.\u001b[0m Oracle\r\n"] +[13.959322, "o", " \u001b[1;36m2\u001b[0m\u001b[1m.\u001b[0m Microsoft SQL Server\r\n\r\n"] +[13.959472, "o", "Database type \u001b[1;35m[1/2]\u001b[0m: "] +[14.901705, "o", "2"] +[15.142046, "o", "\r\n"] +[15.1425, "o", "\r\n"] +[15.143572, "o", " \u001b[1;36m1\u001b[0m\u001b[1m.\u001b[0m TPC-C \u001b[1m(\u001b[0mOLTP transactional\u001b[1m)\u001b[0m\r\n"] +[15.144388, "o", " \u001b[1;36m2\u001b[0m\u001b[1m.\u001b[0m TPC-H \u001b[1m(\u001b[0mOLAP analytical\u001b[1m)\u001b[0m\r\n"] +[15.144599, "o", "\r\n"] +[15.14509, "o", "Benchmark \u001b[1;35m[1/2]\u001b[0m: "] +[16.093055, "o", "1"] +[16.333434, "o", "\r\n"] +[16.333776, "o", "\r\n"] +[16.334461, "o", "\u001b[36m╭─\u001b[0m\u001b[36m────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 3 of 6 — Database Targets\u001b[0m\u001b[36m \u001b[0m\u001b[36m────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mDefine the database hosts to benchmark against.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mEach target gets its own HammerDB Job pod in Kubernetes.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[16.334573, "o", "\r\n"] +[16.334858, "o", "Number of database targets \u001b[1;36m(1)\u001b[0m: "] +[17.284702, "o", "8"] +[17.52506, "o", "\r\n"] +[17.526426, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m1\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[17.527053, "o", " Name \u001b[1;36m(db-01)\u001b[0m: "] +[18.276022, "o", "s"] +[18.366283, "o", "q"] +[18.456255, "o", "l"] +[18.546494, "o", "-"] +[18.636702, "o", "b"] +[18.726931, "o", "e"] +[18.817116, "o", "n"] +[18.907277, "o", "c"] +[18.997431, "o", "h"] +[19.087784, "o", "-"] +[19.178076, "o", "0"] +[19.268139, "o", "1"] +[19.508613, "o", "\r\n"] +[19.509417, "o", " Hostname or IP: "] +[20.259658, "o", "s"] +[20.349945, "o", "q"] +[20.440278, "o", "l"] +[20.530542, "o", "-"] +[20.620906, "o", "b"] +[20.711147, "o", "e"] +[20.801465, "o", "n"] +[20.891642, "o", "c"] +[20.981863, "o", "h"] +[21.071993, "o", "-"] +[21.162246, "o", "0"] +[21.252483, "o", "1"] +[21.342738, "o", "."] +[21.433059, "o", "s"] +[21.523246, "o", "o"] +[21.613494, "o", "l"] +[21.703752, "o", "n"] +[21.794048, "o", "."] +[21.884294, "o", "l"] +[21.974492, "o", "o"] +[22.064669, "o", "c"] +[22.154767, "o", "a"] +[22.245055, "o", "l"] +[22.485433, "o", "\r\n"] +[22.486793, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m2\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[22.487312, "o", " Name \u001b[1;36m(db-02)\u001b[0m: "] +[23.236406, "o", "s"] +[23.326579, "o", "q"] +[23.416962, "o", "l"] +[23.507184, "o", "-"] +[23.597429, "o", "b"] +[23.687648, "o", "e"] +[23.777879, "o", "n"] +[23.868235, "o", "c"] +[23.958301, "o", "h"] +[24.048466, "o", "-"] +[24.138878, "o", "0"] +[24.229083, "o", "2"] +[24.469494, "o", "\r\n"] +[24.470304, "o", " Hostname or IP: "] +[25.220064, "o", "s"] +[25.310171, "o", "q"] +[25.400378, "o", "l"] +[25.49068, "o", "-"] +[25.580929, "o", "b"] +[25.671166, "o", "e"] +[25.761409, "o", "n"] +[25.851661, "o", "c"] +[25.941866, "o", "h"] +[26.032129, "o", "-"] +[26.122328, "o", "0"] +[26.212554, "o", "2"] +[26.302934, "o", "."] +[26.393169, "o", "s"] +[26.483395, "o", "o"] +[26.573716, "o", "l"] +[26.663923, "o", "n"] +[26.75413, "o", "."] +[26.844335, "o", "l"] +[26.934504, "o", "o"] +[27.024701, "o", "c"] +[27.11493, "o", "a"] +[27.205334, "o", "l"] +[27.445776, "o", "\r\n"] +[27.44632, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m3\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[27.446539, "o", " Name \u001b[1;36m(db-03)\u001b[0m: "] +[28.196528, "o", "s"] +[28.286662, "o", "q"] +[28.376886, "o", "l"] +[28.467092, "o", "-"] +[28.557369, "o", "b"] +[28.647589, "o", "e"] +[28.737793, "o", "n"] +[28.82804, "o", "c"] +[28.918105, "o", "h"] +[29.008445, "o", "-"] +[29.098674, "o", "0"] +[29.18891, "o", "3"] +[29.429208, "o", "\r\n"] +[29.43003, "o", " Hostname or IP: "] +[30.180222, "o", "s"] +[30.270454, "o", "q"] +[30.360673, "o", "l"] +[30.450986, "o", "-"] +[30.54116, "o", "b"] +[30.631414, "o", "e"] +[30.721635, "o", "n"] +[30.81195, "o", "c"] +[30.902068, "o", "h"] +[30.992382, "o", "-"] +[31.082552, "o", "0"] +[31.172758, "o", "3"] +[31.262999, "o", "."] +[31.353228, "o", "s"] +[31.443684, "o", "o"] +[31.53394, "o", "l"] +[31.623977, "o", "n"] +[31.714285, "o", "."] +[31.804772, "o", "l"] +[31.894998, "o", "o"] +[31.985265, "o", "c"] +[32.075428, "o", "a"] +[32.165833, "o", "l"] +[32.406124, "o", "\r\n"] +[32.407363, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m4\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[32.407928, "o", " Name \u001b[1;36m(db-04)\u001b[0m: "] +[33.157195, "o", "s"] +[33.247461, "o", "q"] +[33.338003, "o", "l"] +[33.428151, "o", "-"] +[33.518369, "o", "b"] +[33.60869, "o", "e"] +[33.698974, "o", "n"] +[33.789133, "o", "c"] +[33.87935, "o", "h"] +[33.969447, "o", "-"] +[34.05965, "o", "0"] +[34.14978, "o", "4"] +[34.390317, "o", "\r\n"] +[34.390734, "o", " Hostname or IP: "] +[35.141298, "o", "s"] +[35.231314, "o", "q"] +[35.321632, "o", "l"] +[35.411938, "o", "-"] +[35.502128, "o", "b"] +[35.592475, "o", "e"] +[35.682662, "o", "n"] +[35.772914, "o", "c"] +[35.863173, "o", "h"] +[35.953347, "o", "-"] +[36.043602, "o", "0"] +[36.133787, "o", "4"] +[36.223985, "o", "."] +[36.314211, "o", "s"] +[36.404538, "o", "o"] +[36.494829, "o", "l"] +[36.58506, "o", "n"] +[36.675243, "o", "."] +[36.765501, "o", "l"] +[36.855835, "o", "o"] +[36.946004, "o", "c"] +[37.036357, "o", "a"] +[37.126529, "o", "l"] +[37.366935, "o", "\r\n"] +[37.368194, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m5\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[37.368718, "o", " Name \u001b[1;36m(db-05)\u001b[0m: "] +[38.117738, "o", "s"] +[38.207848, "o", "q"] +[38.297939, "o", "l"] +[38.3882, "o", "-"] +[38.478462, "o", "b"] +[38.568675, "o", "e"] +[38.658922, "o", "n"] +[38.749255, "o", "c"] +[38.839607, "o", "h"] +[38.929831, "o", "-"] +[39.020095, "o", "0"] +[39.110326, "o", "5"] +[39.350762, "o", "\r\n"] +[39.351131, "o", " Hostname or IP: "] +[40.101691, "o", "s"] +[40.191811, "o", "q"] +[40.282123, "o", "l"] +[40.372136, "o", "-"] +[40.462415, "o", "b"] +[40.55259, "o", "e"] +[40.642881, "o", "n"] +[40.733053, "o", "c"] +[40.823229, "o", "h"] +[40.913538, "o", "-"] +[41.003594, "o", "0"] +[41.093857, "o", "5"] +[41.184214, "o", "."] +[41.274389, "o", "s"] +[41.364695, "o", "o"] +[41.454948, "o", "l"] +[41.545297, "o", "n"] +[41.635714, "o", "."] +[41.725794, "o", "l"] +[41.816063, "o", "o"] +[41.906194, "o", "c"] +[41.996521, "o", "a"] +[42.086772, "o", "l"] +[42.327159, "o", "\r\n"] +[42.328469, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m6\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[42.329022, "o", " Name \u001b[1;36m(db-06)\u001b[0m: "] +[43.078111, "o", "s"] +[43.168191, "o", "q"] +[43.258473, "o", "l"] +[43.348733, "o", "-"] +[43.438946, "o", "b"] +[43.529137, "o", "e"] +[43.619456, "o", "n"] +[43.709641, "o", "c"] +[43.799701, "o", "h"] +[43.889947, "o", "-"] +[43.980192, "o", "0"] +[44.070424, "o", "6"] +[44.310812, "o", "\r\n"] +[44.311636, "o", " Hostname or IP: "] +[45.061635, "o", "s"] +[45.151833, "o", "q"] +[45.24197, "o", "l"] +[45.332079, "o", "-"] +[45.422197, "o", "b"] +[45.51247, "o", "e"] +[45.602632, "o", "n"] +[45.692789, "o", "c"] +[45.782989, "o", "h"] +[45.873057, "o", "-"] +[45.963337, "o", "0"] +[46.053513, "o", "6"] +[46.143715, "o", "."] +[46.234024, "o", "s"] +[46.324225, "o", "o"] +[46.414837, "o", "l"] +[46.504957, "o", "n"] +[46.594958, "o", "."] +[46.685155, "o", "l"] +[46.775338, "o", "o"] +[46.865611, "o", "c"] +[46.955783, "o", "a"] +[47.046313, "o", "l"] +[47.286788, "o", "\r\n"] +[47.288039, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m7\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[47.288553, "o", " Name \u001b[1;36m(db-07)\u001b[0m: "] +[48.037422, "o", "s"] +[48.127557, "o", "q"] +[48.217773, "o", "l"] +[48.307921, "o", "-"] +[48.398203, "o", "b"] +[48.488338, "o", "e"] +[48.578621, "o", "n"] +[48.668854, "o", "c"] +[48.759121, "o", "h"] +[48.849334, "o", "-"] +[48.939504, "o", "0"] +[49.029794, "o", "7"] +[49.270303, "o", "\r\n"] +[49.271079, "o", " Hostname or IP: "] +[50.02112, "o", "s"] +[50.111465, "o", "q"] +[50.201791, "o", "l"] +[50.291912, "o", "-"] +[50.382125, "o", "b"] +[50.472295, "o", "e"] +[50.562507, "o", "n"] +[50.652687, "o", "c"] +[50.742814, "o", "h"] +[50.833023, "o", "-"] +[50.92332, "o", "0"] +[51.013524, "o", "7"] +[51.103754, "o", "."] +[51.194073, "o", "s"] +[51.284263, "o", "o"] +[51.374502, "o", "l"] +[51.464857, "o", "n"] +[51.554934, "o", "."] +[51.645311, "o", "l"] +[51.735578, "o", "o"] +[51.825625, "o", "c"] +[51.91597, "o", "a"] +[52.006069, "o", "l"] +[52.246774, "o", "\r\n"] +[52.2483, "o", "\r\n \u001b[1;36mTarget \u001b[0m\u001b[1;36m8\u001b[0m\u001b[1;36m of \u001b[0m\u001b[1;36m8\u001b[0m\r\n"] +[52.24886, "o", " Name \u001b[1;36m(db-08)\u001b[0m: "] +[52.997586, "o", "s"] +[53.087604, "o", "q"] +[53.17793, "o", "l"] +[53.268097, "o", "-"] +[53.358422, "o", "b"] +[53.448591, "o", "e"] +[53.538831, "o", "n"] +[53.628945, "o", "c"] +[53.719179, "o", "h"] +[53.809398, "o", "-"] +[53.899669, "o", "0"] +[53.989777, "o", "8"] +[54.230235, "o", "\r\n"] +[54.23106, "o", " Hostname or IP: "] +[54.981129, "o", "s"] +[55.071373, "o", "q"] +[55.161715, "o", "l"] +[55.252039, "o", "-"] +[55.342332, "o", "b"] +[55.432528, "o", "e"] +[55.522762, "o", "n"] +[55.613079, "o", "c"] +[55.703368, "o", "h"] +[55.793474, "o", "-"] +[55.883891, "o", "0"] +[55.974176, "o", "8"] +[56.06457, "o", "."] +[56.154824, "o", "s"] +[56.244907, "o", "o"] +[56.335099, "o", "l"] +[56.425154, "o", "n"] +[56.51561, "o", "."] +[56.605851, "o", "l"] +[56.696047, "o", "o"] +[56.786173, "o", "c"] +[56.876348, "o", "a"] +[56.966715, "o", "l"] +[57.206985, "o", "\r\n"] +[57.207231, "o", "\r\n"] +[57.208721, "o", "\u001b[36m╭─\u001b[0m\u001b[36m──────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 4 of 6 — Credentials\u001b[0m\u001b[36m \u001b[0m\u001b[36m───────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mDatabase authentication shared by all targets.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mYou can override per-host in the YAML later.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[57.208934, "o", "\r\n"] +[57.20945, "o", "Database username \u001b[1;36m(sa)\u001b[0m: "] +[58.158366, "o", "\r\n"] +[58.159112, "o", "Database password: "] +[59.881625, "o", "\r\n"] +[59.882019, "o", "\r\n"] +[59.883553, "o", "\u001b[36m╭─\u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 5 of 6 — Benchmark Parameters\u001b[0m\u001b[36m \u001b[0m\u001b[36m──────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mConfigure the benchmark workload.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[59.883771, "o", "\r\n"] +[59.884892, "o", " \u001b[2mRule of thumb: \u001b[0m\u001b[1;2;36m100\u001b[0m\u001b[2m warehouses ~ \u001b[0m\u001b[1;2;36m10\u001b[0m\u001b[2m GB per target.\u001b[0m\r\n\r\n"] +[59.885418, "o", "Warehouses per target \u001b[1;36m(100)\u001b[0m: "] +[60.832534, "o", "1"] +[60.922714, "o", "0"] +[61.163168, "o", "\r\n"] +[61.163685, "o", "\r\n"] +[61.165224, "o", "\u001b[36m╭─\u001b[0m\u001b[36m─────────────────────────────────\u001b[0m\u001b[36m \u001b[0m\u001b[1;36mStep 6 of 6 — Infrastructure\u001b[0m\u001b[36m \u001b[0m\u001b[36m─────────────────────────────────\u001b[0m\u001b[36m─╮\u001b[0m\r\n\u001b[36m│\u001b[0m \u001b[2mKubernetes and storage settings.\u001b[0m \u001b[36m│\u001b[0m\r\n\u001b[36m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[61.165442, "o", "\r\n"] +[61.165991, "o", "Kubernetes namespace \u001b[1;36m(hammerdb)\u001b[0m: "] +[62.114424, "o", "\r\n"] +[62.114901, "o", "\r\n"] +[62.115638, "o", "Enable Pure Storage metrics collection? \u001b[1;35m[y/n]\u001b[0m \u001b[1;36m(n)\u001b[0m: "] +[62.965616, "o", "\r\n"] +[62.965882, "o", "\r\n"] +[62.966162, "o", "Configure advanced options (VUs, rampup, duration, resources)? \u001b[1;35m[y/n]\u001b[0m \u001b[1;36m(n)\u001b[0m: "] +[63.816275, "o", "y"] +[64.056961, "o", "\r\n"] +[64.058707, "o", "\u001b[33m╭─\u001b[0m\u001b[33m───────────────────────────────────────\u001b[0m\u001b[33m \u001b[0m\u001b[1;33mAdvanced Options\u001b[0m\u001b[33m \u001b[0m\u001b[33m───────────────────────────────────────\u001b[0m\u001b[33m─╮\u001b[0m\r\n\u001b[33m│\u001b[0m \u001b[2mTune concurrency, timing, and pod resources.\u001b[0m \u001b[33m│\u001b[0m\r\n\u001b[33m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n"] +[64.058933, "o", "\r\n"] +[64.059479, "o", "Build virtual users \u001b[1;36m(4)\u001b[0m: "] +[64.808026, "o", "\r\n"] +[64.808901, "o", "Load virtual users \u001b[1;36m(4)\u001b[0m: "] +[65.55893, "o", "8"] +[65.799287, "o", "\r\n"] +[65.799828, "o", "Rampup (minutes) \u001b[1;36m(5)\u001b[0m: "] +[66.5505, "o", "1"] +[66.790835, "o", "\r\n"] +[66.791714, "o", "Duration (minutes) \u001b[1;36m(10)\u001b[0m: "] +[67.54177, "o", "1"] +[67.782253, "o", "\r\n"] +[67.783592, "o", "\r\n \u001b[2mKubernetes pod resources:\u001b[0m\r\n\r\n"] +[67.784143, "o", "Request memory \u001b[1;36m(4Gi)\u001b[0m: "] +[68.532993, "o", "\r\n"] +[68.533843, "o", "Request CPU \u001b[1;36m(4)\u001b[0m: "] +[69.283953, "o", "\r\n"] +[69.284803, "o", "Limit memory \u001b[1;36m(8Gi)\u001b[0m: "] +[70.035116, "o", "\r\n"] +[70.035969, "o", "Limit CPU \u001b[1;36m(8)\u001b[0m: "] +[70.786125, "o", "\r\n"] +[70.786679, "o", "\r\n"] +[70.799256, "o", "\u001b[32m╭─\u001b[0m\u001b[32m────────────────────────────────────\u001b[0m\u001b[32m \u001b[0m\u001b[1;32mConfiguration Summary\u001b[0m\u001b[32m \u001b[0m\u001b[32m─────────────────────────────────────\u001b[0m\u001b[32m─╮\u001b[0m\r\n\u001b[32m│\u001b[0m ┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┓ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m ┃\u001b[1m \u001b[0m\u001b[1mSetting \u001b[0m\u001b[1m \u001b[0m┃\u001b[1m \u001b[0m\u001b[1mValue \u001b[0m\u001b[1m \u001b[0m┃ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m ┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┩ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mDeployment name \u001b[0m\u001b[36m \u001b[0m│ mssql-8-tprocc-demo │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mDatabase type \u001b[0m\u001b[36m \u001b[0m│ SQL Server │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mBenchmark \u001b[0m\u001b[36m \u001b[0m│ TPC-C (OLTP) │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mTargets \u001b[0m\u001b[36m \u001b[0m│ 8 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-01 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-01.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-02 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-02.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-03 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-03.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-04 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-04.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-05 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-05.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-06 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-06.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-07 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-07.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[2;36m \u001b[0m\u001b[2;36m sql-bench-08 \u001b[0m\u001b[2;36m \u001b[0m│\u001b[2m \u001b[0m\u001b[2msql-bench-08.soln.local \u001b[0m\u001b[2m \u001b[0m│ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mUsername \u001b[0m\u001b[36m \u001b[0m│ sa │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mPassword \u001b[0m\u001b[36m \u001b[0m│ ******** │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mWarehouses \u001b[0m\u001b[36m \u001b[0m│ 10 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mNamespace \u001b[0m\u001b[36m \u001b[0m│ hammerdb │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mPure Storage \u001b[0m\u001b[36m \u001b[0m│ Disabled │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m ├─────────────"] +[70.799546, "o", "─────────────────────────────────┼───────────────────────────────────────────────┤ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mLoad VUs \u001b[0m\u001b[36m \u001b[0m│ 8 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mRampup (min) \u001b[0m\u001b[36m \u001b[0m│ 1 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m │\u001b[36m \u001b[0m\u001b[36mDuration (min) \u001b[0m\u001b[36m \u001b[0m│ 1 │ \u001b[32m│\u001b[0m\r\n\u001b[32m│\u001b[0m └──────────────────────────────────────────────┴───────────────────────────────────────────────┘ \u001b[32m│\u001b[0m\r\n\u001b[32m╰──────────────────────────────────────────────────────────────────────────────────────────────────╯\u001b[0m\r\n\r\n"] +[70.799709, "o", "Write configuration? \u001b[1;35m[y/n]\u001b[0m \u001b[1;36m(y)\u001b[0m: "] +[72.838201, "o", "\r\n"] +[72.839926, "o", "\r\n\u001b[32mConfig written to \u001b[0m\u001b[32m/tmp/\u001b[0m\u001b[32mhammerdb-scale-demo.yaml\u001b[0m\r\n"] +[72.840486, "o", "\r\nNext steps:\r\n"] +[72.841117, "o", " hammerdb-scale validate Check config and prerequisites\r\n"] +[72.841764, "o", " hammerdb-scale run --build Build schemas and run benchmark\r\n"] +[72.842585, "o", " hammerdb-scale run Run benchmark \u001b[1m(\u001b[0mif schemas already built\u001b[1m)\u001b[0m\r\n"] +[72.885874, "o", "[root@august hammerdb-scale]# "] +[75.889954, "o", "c"] +[75.980126, "o", "a"] +[76.070228, "o", "t"] +[76.160514, "o", " "] +[76.250684, "o", "/"] +[76.340999, "o", "t"] +[76.431161, "o", "m"] +[76.521537, "o", "p"] +[76.611685, "o", "/"] +[76.701948, "o", "h"] +[76.79213, "o", "a"] +[76.882341, "o", "m"] +[76.972498, "o", "m"] +[77.062904, "o", "e"] +[77.153351, "o", "r"] +[77.243569, "o", "d"] +[77.333618, "o", "b"] +[77.424121, "o", "-"] +[77.514154, "o", "s"] +[77.604378, "o", "c"] +[77.694729, "o", "a"] +[77.7847, "o", "l"] +[77.8751, "o", "e"] +[77.965318, "o", "-"] +[78.055687, "o", "d"] +[78.145807, "o", "e"] +[78.236129, "o", "m"] +[78.326462, "o", "o"] +[78.416722, "o", "."] +[78.507055, "o", "y"] +[78.597236, "o", "a"] +[78.687558, "o", "m"] +[78.777764, "o", "l"] +[79.017894, "o", "\r\n"] +[79.02118, "o", "# ============================================================================\r\n# HammerDB-Scale Configuration\r\n# ============================================================================\r\n# Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md\r\n# Guide: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md\r\n#\r\n# Quick start:\r\n# 1. Review and edit the values below (especially targets, warehouses, and virtual users)\r\n# 2. hammerdb-scale validate # check config, tools, and database connectivity\r\n# 3. hammerdb-scale run --build --wait # build schemas and run the benchmark\r\n# 4. hammerdb-scale results # aggregate results\r\n# 5. hammerdb-scale report --open # generate HTML scorecard\r\n# ============================================================================\r\n\r\nname: mssql-8-tprocc-demo\r\ndescription: \"MSSQL TPROCC benchmark — 8 targets\"\r\ndefault_benchmark: tprocc\r\n\r\n# ============================================================================\r\n# DATABASE TARGETS\r\n# ============================================================================\r\n"] +[79.021326, "o", "# \"defaults\" are inherited by all hosts — override any field per-host if needed.\r\n# Add or remove hosts to match your environment.\r\ntargets:\r\n defaults:\r\n type: mssql\r\n username: sa\r\n password: \"Osmium76\"\r\n image:\r\n repository: sillidata/hammerdb-scale\r\n tag: latest\r\n pull_policy: Always # Always | IfNotPresent | Never\r\n mssql:\r\n port: 1433\r\n connection:\r\n tcp: true\r\n authentication: sql\r\n odbc_driver: \"ODBC Driver 18 for SQL Server\"\r\n encrypt_connection: true # TLS encryption (recommended)\r\n trust_server_cert: true # accept self-signed certs\r\n tprocc:\r\n database_name: tpcc # database created during build phase\r\n use_bcp: false # bulk copy for faster data loading\r\n tproch:\r\n database_name: tpch # database created during build phase\r\n maxdop: 2 # max degree of parallelism for queries\r\n use_clustered_columnstore: false # columnstore indexes (analytics optimization)\r\n\r\n hosts:\r\n - name: sql-bench-01\r\n host: \"sql-bench-01.soln.local\"\r\n - name: sql-bench-02\r\n host: \"sql-bench-02.soln.local\"\r\n - name: sql-bench-03\r\n host: \"sql-bench-03.soln.local\"\r\n - name: sql-bench-04\r\n host: \"sql-bench-04.soln.local\"\r\n - name: sql-bench-05\r\n host: \"sql-bench-05.soln.local\"\r\n - name: sql-bench-06\r\n host: \"sql-bench-06.soln.local\"\r\n - name: sql-bench-07\r\n host: \"sql-bench-07.soln.local\"\r\n - name: sql-bench-08\r\n host: \"sql-bench-08.soln.local\"\r\n\r\n# ============================================================================\r\n# BENCHMARK PARAMETERS\r\n# ============================================================================\r\n# Tune these to control data size, concurrency, and test duration.\r\n# Uncomment the other benchmark section to switch between TPC-C and TPC-H.\r\nhammerdb:\r\n tprocc:\r\n warehouses: 10 # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target\r\n build_virtual_users: 4 # parallel threads for schema creation\r\n load_virtual_users: 8 # concurrent users during benchmark (tune to match CPU cores)\r\n driver: timed # \"timed\" = run for fixed duration, \"test\" = single iteration\r\n rampup: 1 # warm-up period before measuring (minutes)\r\n duration: 1 # measurement window (minutes), 5-60 typical\r\n total_iterations: 10000000 # max iterations (effectively unlimited with timed driver)\r\n all_warehouses: true # distribute load across all warehouses\r\n checkpoint: true # issue checkpoint before benchmark\r\n"] +[79.021478, "o", " time_profile: false # detailed per-transaction timing (adds overhead)\r\n# tproch:\r\n# scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB\r\n# build_threads: 4 # parallel threads for data generation\r\n# build_virtual_users: 1 # HammerDB orchestrator (keep at 1)\r\n# load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run\r\n# total_querysets: 1 # number of full 22-query runs\r\n\r\n# ============================================================================\r\n# KUBERNETES RESOURCES\r\n# ============================================================================\r\n# Resource requests/limits for each HammerDB Job pod.\r\n# Each target gets one pod — scale these based on virtual user count.\r\nresources:\r\n requests:\r\n memory: \"4Gi\"\r\n cpu: \"4\"\r\n limits:\r\n memory: \"8Gi\"\r\n cpu: \"8\"\r\n\r\n# ============================================================================\r\n# KUBERNETES SETTINGS\r\n# ============================================================================\r\nkubernetes:\r\n namespace: hammerdb # namespace for benchmark jobs (must exist)\r\n job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h)\r\n\r\n# ============================================================================\r\n# PURE STORAGE METRICS (optional)\r\n# Uncomment to collect IOPS, latency, and bandwidth from a FlashArray\r\n# during benchmarks. Metrics appear in the HTML scorecard.\r\n# ============================================================================\r\nstorage_metrics:\r\n enabled: false\r\n # pure:\r\n # host: \"\" # FlashArray management IP or hostname\r\n # api_token: \"\" # REST API token (Settings > Users > API Tokens)\r\n # volume: \"\" # leave empty for array-level metrics\r\n # poll_interval: 5 # collection interval in seconds\r\n # verify_ssl: false\r\n"] +[79.022096, "o", "[root@august hammerdb-scale]# "] +[81.069097, "o", "h"] +[81.159441, "o", "a"] +[81.249637, "o", "m"] +[81.339768, "o", "m"] +[81.429788, "o", "e"] +[81.520136, "o", "r"] +[81.610934, "o", "d"] +[81.700552, "o", "b"] +[81.790644, "o", "-"] +[81.880809, "o", "s"] +[81.970872, "o", "c"] +[82.061193, "o", "a"] +[82.151391, "o", "l"] +[82.241356, "o", "e"] +[82.331503, "o", " "] +[82.4219, "o", "v"] +[82.511931, "o", "a"] +[82.602043, "o", "l"] +[82.692268, "o", "i"] +[82.782659, "o", "d"] +[82.873131, "o", "a"] +[82.963205, "o", "t"] +[83.053485, "o", "e"] +[83.143491, "o", " "] +[83.234209, "o", "-"] +[83.324607, "o", "f"] +[83.414719, "o", " "] +[83.505036, "o", "/"] +[83.595337, "o", "t"] +[83.685522, "o", "m"] +[83.77585, "o", "p"] +[83.866006, "o", "/"] +[83.956215, "o", "h"] +[84.046288, "o", "a"] +[84.136799, "o", "m"] +[84.227176, "o", "m"] +[84.317452, "o", "e"] +[84.407672, "o", "r"] +[84.497911, "o", "d"] +[84.588252, "o", "b"] +[84.678493, "o", "-"] +[84.768731, "o", "s"] +[84.858925, "o", "c"] +[84.949242, "o", "a"] +[85.039495, "o", "l"] +[85.129708, "o", "e"] +[85.219926, "o", "-"] +[85.310193, "o", "d"] +[85.400431, "o", "e"] +[85.490713, "o", "m"] +[85.580915, "o", "o"] +[85.671148, "o", "."] +[85.761365, "o", "y"] +[85.851094, "o", "a"] +[85.941374, "o", "m"] +[86.031934, "o", "l"] +[86.271588, "o", "\r\n"] +[86.513453, "o", "Validating configuration\u001b[33m...\u001b[0m\r\n"] +[86.521256, "o", " \u001b[32m+\u001b[0m YAML syntax valid\r\n"] +[86.528828, "o", " \u001b[32m+\u001b[0m Schema validates \u001b[1m(\u001b[0mall required fields present\u001b[1m)\u001b[0m\r\n"] +[86.529155, "o", " \u001b[32m+\u001b[0m \u001b[1;36m8\u001b[0m targets configured \u001b[1m(\u001b[0mtype: mssql\u001b[1m)\u001b[0m\r\n"] +[86.529471, "o", " \u001b[32m+\u001b[0m Benchmark settings valid \u001b[1m(\u001b[0mtprocc, \u001b[1;36m10\u001b[0m warehouses\u001b[1m)\u001b[0m\r\n"] +[86.52971, "o", " \u001b[32m+\u001b[0m Default benchmark: tprocc\r\n"] +[86.530015, "o", "\r\nChecking prerequisites\u001b[33m...\u001b[0m\r\n"] +[86.582595, "o", " \u001b[32m+\u001b[0m helm found \u001b[1m(\u001b[0mv3.\u001b[1;36m19.4\u001b[0m+g7cfb6e4\u001b[1m)\u001b[0m\r\n"] +[86.785664, "o", " \u001b[32m+\u001b[0m kubectl found \u001b[1m(\u001b[0m\u001b[1m)\u001b[0m\r\n"] +[86.786302, "o", " \u001b[32m+\u001b[0m Current context: admin\r\n"] +[86.78707, "o", "\r\nChecking Kubernetes access\u001b[33m...\u001b[0m\r\n"] +[86.919517, "o", " \u001b[33m!\u001b[0m Namespace \u001b[32m'hammerdb'\u001b[0m does not exist. It will be created during deployment.\r\n"] +[87.05105, "o", " \u001b[32m+\u001b[0m Can create Jobs in namespace\r\n"] +[87.184252, "o", " \u001b[32m+\u001b[0m Can create ConfigMaps in namespace\r\n"] +[87.184514, "o", "\r\nChecking database connectivity\u001b[33m...\u001b[0m\r\n"] +[87.393312, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m05\u001b[0m sql-bench-\u001b[1;36m05.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.395281, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m03\u001b[0m sql-bench-\u001b[1;36m03.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.398472, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m07\u001b[0m sql-bench-\u001b[1;36m07.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.399767, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m08\u001b[0m sql-bench-\u001b[1;36m08.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.401599, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m04\u001b[0m sql-bench-\u001b[1;36m04.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.403038, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m06\u001b[0m sql-bench-\u001b[1;36m06.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.406043, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m02\u001b[0m sql-bench-\u001b[1;36m02.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.407358, "o", " \u001b[32m+\u001b[0m sql-bench-\u001b[1;36m01\u001b[0m sql-bench-\u001b[1;36m01.\u001b[0msoln.local:\u001b[1;36m1433\u001b[0m Connected \u001b[1m(\u001b[0muser: sa\u001b[1m)\u001b[0m\r\n"] +[87.408567, "o", "\r\nValidation complete: \u001b[1;36m0\u001b[0m \u001b[1;35mwarning\u001b[0m\u001b[1m(\u001b[0ms\u001b[1m)\u001b[0m, \u001b[1;36m0\u001b[0m errors.\r\n"] +[87.451304, "o", "[root@august hammerdb-scale]# "] +[88.322511, "o", "e"] +[88.412699, "o", "x"] +[88.502852, "o", "i"] +[88.593027, "o", "t"] +[88.833484, "o", "\r\n"] +[88.833771, "o", "exit\r\n"] diff --git a/demos/record_benchmark.py b/demos/record_benchmark.py new file mode 100644 index 0000000..0802b21 --- /dev/null +++ b/demos/record_benchmark.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3.11 +"""Record the 'Benchmark Payoff' asciinema demo for hammerdb-scale. + +Assumes schemas are already built (pre-build before recording). +Shows: run → wait → results → report → clean +""" + +import os +import time + +import pexpect + +# ── Config ────────────────────────────────────────────────────────────── +CAST_FILE = os.path.join(os.path.dirname(__file__), "benchmark-payoff.cast") +YAML_FILE = "/tmp/hammerdb-scale-demo.yaml" +F = f"-f {YAML_FILE}" + +# Specific prompt pattern: [root@august hammerdb-scale]# +PROMPT = r"hammerdb-scale\]#" + +FAST = 0.04 +NORMAL = 0.06 +PAUSE = 1.0 +LONG_PAUSE = 2.5 + + +def typed(child, text, speed=NORMAL): + for ch in text: + child.send(ch) + time.sleep(speed) + time.sleep(0.15) + + +def enter(child): + child.send("\r") + time.sleep(0.3) + + +def type_and_enter(child, text, speed=NORMAL): + typed(child, text, speed) + enter(child) + + +def wait_for_prompt(child, timeout=600): + """Wait for the bash prompt.""" + child.expect(PROMPT, timeout=timeout) + time.sleep(0.5) + + +def main(): + if not os.path.exists(YAML_FILE): + print(f"ERROR: {YAML_FILE} not found. Run record_getting_started.py first.") + return + + os.environ["COLUMNS"] = "100" + os.environ["LINES"] = "35" + + child = pexpect.spawn( + f"asciinema rec --cols 100 --rows 35 --idle-time-limit 3 " + f"--title 'hammerdb-scale: Benchmark Run' " + f"--overwrite {CAST_FILE}", + encoding="utf-8", + timeout=600, + ) + child.setwinsize(35, 100) + + wait_for_prompt(child) + time.sleep(PAUSE) + + # ── Run benchmark ─────────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale run {F} --wait", FAST) + # rampup=1 + duration=1 = ~3 min total + wait_for_prompt(child, timeout=600) + time.sleep(LONG_PAUSE) + + # ── Results ───────────────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale results {F}", FAST) + wait_for_prompt(child, timeout=60) + time.sleep(LONG_PAUSE) + + # ── Report ────────────────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale report {F} -o /tmp/scorecard.html", FAST) + wait_for_prompt(child, timeout=60) + time.sleep(LONG_PAUSE) + + # ── Clean ─────────────────────────────────────────────────────────── + type_and_enter( + child, + f"hammerdb-scale clean {F} --resources --database " + f"--benchmark tprocc --everything --force", + FAST, + ) + wait_for_prompt(child, timeout=120) + time.sleep(LONG_PAUSE) + + # ── Exit ──────────────────────────────────────────────────────────── + type_and_enter(child, "exit", FAST) + child.expect(pexpect.EOF, timeout=10) + child.close() + + print(f"\nRecording saved to: {CAST_FILE}") + print(f"Convert to GIF: agg {CAST_FILE} benchmark-payoff.gif") + print(f"Upload: asciinema upload {CAST_FILE}") + + +if __name__ == "__main__": + main() diff --git a/demos/record_getting_started.py b/demos/record_getting_started.py new file mode 100644 index 0000000..e1cdbd4 --- /dev/null +++ b/demos/record_getting_started.py @@ -0,0 +1,227 @@ +#!/usr/bin/env python3.11 +"""Record the 'Getting Started' asciinema demo for hammerdb-scale. + +Drives the interactive wizard via pexpect with realistic typing delays, +all inside an asciinema recording session. +""" + +import os +import sys +import time + +import pexpect + +# ── Config ────────────────────────────────────────────────────────────── +CAST_FILE = os.path.join(os.path.dirname(__file__), "getting-started.cast") +YAML_OUTPUT = "/tmp/hammerdb-scale-demo.yaml" + +# Typing speed (seconds per character) +FAST = 0.04 +NORMAL = 0.06 +PAUSE = 0.8 # pause between commands +LONG_PAUSE = 1.5 + +HOSTS = [ + ("sql-bench-01", "sql-bench-01.soln.local"), + ("sql-bench-02", "sql-bench-02.soln.local"), + ("sql-bench-03", "sql-bench-03.soln.local"), + ("sql-bench-04", "sql-bench-04.soln.local"), + ("sql-bench-05", "sql-bench-05.soln.local"), + ("sql-bench-06", "sql-bench-06.soln.local"), + ("sql-bench-07", "sql-bench-07.soln.local"), + ("sql-bench-08", "sql-bench-08.soln.local"), +] + + +def typed(child, text, speed=NORMAL): + """Send text character by character with delay.""" + for ch in text: + child.send(ch) + time.sleep(speed) + time.sleep(0.15) + + +def enter(child): + """Press Enter.""" + child.send("\r") + time.sleep(0.3) + + +def type_and_enter(child, text, speed=NORMAL): + """Type text then press Enter.""" + typed(child, text, speed) + enter(child) + + +def wait_for(child, pattern, timeout=30): + """Wait for a pattern in output.""" + child.expect(pattern, timeout=timeout) + time.sleep(0.2) + + +def main(): + # Clean up any previous output + if os.path.exists(YAML_OUTPUT): + os.remove(YAML_OUTPUT) + + # Set terminal size for clean recording + os.environ["COLUMNS"] = "100" + os.environ["LINES"] = "30" + + # Start asciinema recording wrapping a bash shell + child = pexpect.spawn( + f"asciinema rec --cols 100 --rows 30 --idle-time-limit 2 " + f"--title 'hammerdb-scale: Getting Started' " + f"--overwrite {CAST_FILE}", + encoding="utf-8", + timeout=60, + ) + child.setwinsize(30, 100) + + # Wait for shell prompt + wait_for(child, r"[\$#]") + time.sleep(PAUSE) + + # ── Step 0: pip install (quick show) ──────────────────────────────── + type_and_enter(child, "pip install hammerdb-scale", FAST) + wait_for(child, r"[\$#]", timeout=60) + time.sleep(PAUSE) + + # ── Step 1: init wizard ───────────────────────────────────────────── + type_and_enter( + child, + f"hammerdb-scale init -i -o {YAML_OUTPUT} --force", + FAST, + ) + + # Step 1: Deployment name + wait_for(child, r"Deployment name") + time.sleep(0.5) + type_and_enter(child, "mssql-8-tprocc-demo", FAST) + + # Step 2: Database type — select 2 for MSSQL + wait_for(child, r"Database type") + time.sleep(0.4) + type_and_enter(child, "2", FAST) + + # Benchmark — select 1 for TPC-C + wait_for(child, r"Benchmark") + time.sleep(0.4) + type_and_enter(child, "1", FAST) + + # Step 3: Number of targets + wait_for(child, r"Number of database targets") + time.sleep(0.4) + type_and_enter(child, "8", FAST) + + # Enter all 8 hosts + for i, (name, host) in enumerate(HOSTS): + wait_for(child, r"Name") + time.sleep(0.2) + type_and_enter(child, name, FAST) + wait_for(child, r"Hostname or IP") + time.sleep(0.2) + type_and_enter(child, host, FAST) + + # Step 4: Credentials + wait_for(child, r"Database username") + time.sleep(0.4) + # Accept default "sa" + enter(child) + + wait_for(child, r"Database password") + time.sleep(0.3) + type_and_enter(child, "Osmium76", FAST) + + # Step 5: Warehouses + wait_for(child, r"Warehouses per target") + time.sleep(0.4) + type_and_enter(child, "10", FAST) + + # Step 6: Namespace + wait_for(child, r"Kubernetes namespace") + time.sleep(0.4) + # Accept default "hammerdb" + enter(child) + + # Pure Storage metrics + wait_for(child, r"Pure Storage") + time.sleep(0.3) + # Default is No + enter(child) + + # Advanced options — YES, to set fast timing for demo + wait_for(child, r"advanced options") + time.sleep(0.3) + type_and_enter(child, "y", FAST) + + # Build VUs — accept default 4 + wait_for(child, r"Build virtual users") + time.sleep(0.2) + enter(child) + + # Load VUs — set to 8 + wait_for(child, r"Load virtual users") + time.sleep(0.2) + type_and_enter(child, "8", FAST) + + # Rampup — 1 minute (fast demo) + wait_for(child, r"Rampup") + time.sleep(0.2) + type_and_enter(child, "1", FAST) + + # Duration — 1 minute (fast demo) + wait_for(child, r"Duration") + time.sleep(0.2) + type_and_enter(child, "1", FAST) + + # Pod resources — accept all defaults + wait_for(child, r"Request memory") + time.sleep(0.2) + enter(child) + + wait_for(child, r"Request CPU") + time.sleep(0.2) + enter(child) + + wait_for(child, r"Limit memory") + time.sleep(0.2) + enter(child) + + wait_for(child, r"Limit CPU") + time.sleep(0.2) + enter(child) + + # Confirm write + wait_for(child, r"Write configuration") + time.sleep(LONG_PAUSE) # Let viewer read the summary + enter(child) # Yes (default) + + # Wait for file written confirmation + wait_for(child, r"Config written") + time.sleep(LONG_PAUSE) + + # ── Step 2: Show the YAML ─────────────────────────────────────────── + wait_for(child, r"[\$#]") + time.sleep(PAUSE) + type_and_enter(child, f"cat {YAML_OUTPUT}", FAST) + wait_for(child, r"[\$#]", timeout=10) + time.sleep(LONG_PAUSE) + + # ── Step 3: Validate ──────────────────────────────────────────────── + type_and_enter(child, f"hammerdb-scale validate -f {YAML_OUTPUT}", FAST) + wait_for(child, r"[\$#]", timeout=30) + time.sleep(LONG_PAUSE) + + # ── Done: exit the shell to stop recording ────────────────────────── + type_and_enter(child, "exit", FAST) + child.expect(pexpect.EOF, timeout=10) + child.close() + + print(f"\nRecording saved to: {CAST_FILE}") + print(f"Convert to GIF: agg {CAST_FILE} getting-started.gif") + print(f"Upload: asciinema upload {CAST_FILE}") + + +if __name__ == "__main__": + main() diff --git a/dockerfile b/dockerfile deleted file mode 100644 index 8266e82..0000000 --- a/dockerfile +++ /dev/null @@ -1,69 +0,0 @@ -# HammerDB Scale - Base Image -# Supports: SQL Server, PostgreSQL, MySQL (Oracle requires extension image) -# -# This is the public base image containing HammerDB 5.0 and open-source database drivers. -# For Oracle support, build the extension image using Dockerfile.oracle -# -# BUILD: -# docker build -t sillidata/hammerdb-scale:latest . -# -# ORACLE USERS: -# docker build -f Dockerfile.oracle -t myregistry/hammerdb-scale-oracle:latest . - -FROM ubuntu:24.04 - -LABEL maintainer="hammerdb-scale" -LABEL description="HammerDB Scale Test Runner - Multi-Database Performance Testing" -LABEL hammerdb.version="5.0" -LABEL database.support="mssql,postgresql,mysql (oracle via extension)" - -# Set environment variables -ENV DEBIAN_FRONTEND=noninteractive - -# Install base packages and SQL Server drivers (Microsoft - permissive license) -RUN apt-get update && \ - apt-get install -y \ - apt-transport-https \ - curl \ - gnupg2 \ - wget \ - python3 \ - python3-pip \ - vim && \ - # Add Microsoft repository for SQL Server tools - curl -sSL -O https://packages.microsoft.com/config/ubuntu/24.04/packages-microsoft-prod.deb && \ - dpkg -i packages-microsoft-prod.deb && \ - rm packages-microsoft-prod.deb && \ - apt-get update && \ - ACCEPT_EULA=Y apt-get install -y mssql-tools18 msodbcsql18 unixodbc unixodbc-dev && \ - echo 'export PATH="$PATH:/opt/mssql-tools18/bin"' >> ~/.bashrc && \ - apt-get clean && \ - rm -rf /var/lib/apt/lists/* /var/apt/cache/* /tmp/* /var/tmp/* - -# Install Python dependencies for Pure Storage metrics collection -RUN python3 -m pip install --no-cache-dir --break-system-packages requests urllib3 - -# Install HammerDB 5.0 -WORKDIR /opt -RUN wget https://github.com/TPC-Council/HammerDB/releases/download/v5.0/HammerDB-5.0-Prod-Lin-UBU24.tar.gz && \ - tar -xzf HammerDB-5.0-Prod-Lin-UBU24.tar.gz && \ - rm HammerDB-5.0-Prod-Lin-UBU24.tar.gz && \ - echo 'export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu/:$LD_LIBRARY_PATH' >> ~/.bashrc - -# Configure HammerDB -WORKDIR /opt/HammerDB-5.0 -RUN chmod +x ./hammerdbcli && \ - ln -sf /opt/mssql-tools18/bin/bcp /opt/HammerDB-5.0/bcp && \ - ln -sf /opt/mssql-tools18/bin/bcp /usr/local/bin/bcp - -# Add entrypoint script -COPY entrypoint.sh /opt/HammerDB-5.0/entrypoint.sh -RUN chmod +x /opt/HammerDB-5.0/entrypoint.sh - -# Add Pure Storage metrics collection script -COPY scripts/collect_pure_metrics.py /opt/HammerDB-5.0/scripts/collect_pure_metrics.py -RUN chmod +x /opt/HammerDB-5.0/scripts/collect_pure_metrics.py - -WORKDIR /opt/HammerDB-5.0 - -ENTRYPOINT ["/opt/HammerDB-5.0/entrypoint.sh"] diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 58b5fde..222bbf2 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -4,10 +4,11 @@ HammerDB-Scale uses a YAML config file to define database targets and benchmark ## Minimal Example -The smallest valid config — two SQL Server targets running TPC-C: +The smallest valid config: two SQL Server targets running TPC-C on a machine with podman. ```yaml name: my-benchmark +backend: podman default_benchmark: tprocc targets: @@ -31,7 +32,52 @@ hammerdb: duration: 5 ``` -Everything else has sensible defaults. For complete examples covering all database and benchmark combinations, see the [examples/](../examples/) directory. +Everything else has sensible defaults. + +### Running on Kubernetes + +Omit `backend` to run on Kubernetes instead; it is the default. The same config works, minus the `backend` line, but the `kubernetes` and `resources` sections now apply: + +```yaml +name: my-benchmark +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "YourPassword" + mssql: {} + hosts: + - name: sql-01 + host: sql-01.example.com + - name: sql-02 + host: sql-02.example.com + +hammerdb: + tprocc: + warehouses: 100 + load_virtual_users: 4 + driver: timed + rampup: 2 + duration: 5 + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 +``` + +This needs Helm, kubectl, and a cluster context with permission to create Jobs, ConfigMaps, and Secrets in the target namespace. See the README's [Running on Kubernetes instead](../README.md#running-on-kubernetes-instead) section for the full requirements. + +For complete examples covering all database and benchmark combinations, see the [examples/](../examples/) directory. ## Config File Discovery @@ -44,17 +90,19 @@ The CLI looks for the config file in this order: ## Schema Overview -A config file has five top-level sections: +A config file has seven top-level sections: | Section | Purpose | |---------|---------| -| `targets` | **Where** to benchmark — database hosts, credentials, and database-specific settings | -| `hammerdb` | **How** to benchmark — shared benchmark parameters (warehouses, VUs, duration) | -| `resources` | Kubernetes pod resource requests and limits | -| `kubernetes` | Namespace and job TTL settings | +| `backend` | Where workers run: `kubernetes` (default), `podman`, `docker`, or `container` to auto-detect podman or docker | +| `targets` | Where to benchmark: database hosts, credentials, and database-specific settings | +| `hammerdb` | How to benchmark: shared benchmark parameters (warehouses, VUs, duration) | +| `resources` | Memory and CPU limits, applied as Kubernetes pod resources or as `--memory`/`--cpus` on the container backend. `requests` is Kubernetes-only | +| `kubernetes` | Namespace and job TTL settings. Ignored for `podman`/`docker`/`container` backends | +| `container` | Runtime selection and network settings for the `podman`/`docker`/`container` backends. Ignored on Kubernetes | | `storage_metrics` | Optional Pure Storage metrics collection | -Key design principle: **all database-specific settings live under `targets.defaults.`**, not under `hammerdb`. The `hammerdb` section contains only shared benchmark parameters that apply regardless of database type. This makes every database-specific setting overridable per-host. +All database-specific settings live under `targets.defaults.`, not under `hammerdb`. The `hammerdb` section contains only shared benchmark parameters that apply regardless of database type. This makes every database-specific setting overridable per-host. ## Complete Field Reference @@ -62,9 +110,10 @@ Key design principle: **all database-specific settings live under `targets.defau | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `name` | string | yes | | Deployment identifier. Used in K8s job names and test run IDs. | +| `name` | string | yes | | Deployment identifier. Used in job names and test run IDs. | | `description` | string | no | `""` | Optional description for this benchmark configuration. | | `default_benchmark` | string | no | `null` | Default benchmark type when not specified on the CLI. Valid values: `tprocc`, `tproch`. | +| `backend` | string | no | `"kubernetes"` | Where workers run. Valid values: `kubernetes`, `podman`, `docker`, `container` (auto-detect podman or docker). | ### `targets` @@ -77,7 +126,7 @@ Key design principle: **all database-specific settings live under `targets.defau | Field | Type | Required | Default | Description | |-------|------|----------|---------|-------------| -| `type` | string | yes | | Database type: `oracle` or `mssql`. | +| `type` | string | yes | | Database type: `oracle`, `mssql`, or `postgres`. | | `username` | string | yes | | Database login username. Typical values: `system` (Oracle), `sa` (MSSQL). | | `password` | string | yes | | Database login password. | | `image.repository` | string | no | `"sillidata/hammerdb-scale"` | Container image repository. Use `sillidata/hammerdb-scale-oracle` for Oracle targets. | @@ -85,6 +134,7 @@ Key design principle: **all database-specific settings live under `targets.defau | `image.pull_policy` | string | no | `"Always"` | K8s image pull policy: `Always`, `IfNotPresent`, or `Never`. | | `oracle` | object | conditional | | Oracle-specific settings. **Required** when `type: oracle`. | | `mssql` | object | conditional | | MSSQL-specific settings. **Required** when `type: mssql`. | +| `postgres` | object | conditional | | PostgreSQL-specific settings. **Required** when `type: postgres`. | ### `targets.defaults.oracle` @@ -120,13 +170,33 @@ All fields are per-host overridable. | `tproch.maxdop` | int | `2` | Maximum degree of parallelism for TPC-H queries. Min: 1. | | `tproch.use_clustered_columnstore` | bool | `false` | Use clustered columnstore indexes for TPC-H tables. Improves analytical query performance at the cost of longer build times. | +### `targets.defaults.postgres` + +All fields are per-host overridable. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `port` | int | `5432` | PostgreSQL listener port. Range: 1-65535. | +| `sslmode` | string | `"prefer"` | TLS mode: `prefer`, `require`, or `disable`. | +| `tablespace` | string | `""` | Tablespace for benchmark schema objects. Empty uses the default tablespace. | +| `tprocc.database_name` | string | `"tpcc"` | Database created during the schema build. | +| `tprocc.user` | string | `"tpcc"` | Schema owner, created during the schema build. | +| `tprocc.password` | string | `"tpcc"` | Password for the TPC-C schema user. | +| `tprocc.stored_procedures` | bool | `true` | Use stored procedures for the TPC-C driver. HammerDB's recommended setting; must match between build and run, since the driver calls procedures that only exist if the schema was built with them. | +| `tprocc.partition` | bool | `false` | Partition TPC-C tables during the schema build. | +| `tprocc.vacuum` | bool | `false` | Run `VACUUM` after the schema build. | +| `tproch.database_name` | string | `"tpch"` | Database created during the schema build. | +| `tproch.user` | string | `"tpch"` | Schema owner, created during the schema build. | +| `tproch.password` | string | `"tpch"` | Password for the TPC-H schema user. | +| `tproch.max_parallel_workers` | int | `8` | Degree of parallelism for the TPC-H query phase. Min: 1. | + ### `targets.hosts[]` -Each host inherits all fields from `targets.defaults`. Any field can be overridden per-host, including nested fields under `oracle` or `mssql`. +Each host inherits all fields from `targets.defaults`. Any field can be overridden per-host, including nested fields under `oracle`, `mssql`, or `postgres`. | Field | Type | Required | Description | |-------|------|----------|-------------| -| `name` | string | yes | Unique identifier for this host. Used in K8s job names. | +| `name` | string | yes | Unique identifier for this host. Used in job names. | | `host` | string | yes | Hostname or IP address of the database server. | | All `targets.defaults` fields | | no | Any field from defaults can be overridden here. | @@ -159,23 +229,36 @@ Shared benchmark parameters. These apply to all database types. **No database-sp | `load_virtual_users` | int | `1` | Number of virtual users for the query execution phase. Min: 1. | | `total_querysets` | int | `1` | Number of complete TPC-H query set iterations to execute. Min: 1. | +### `container` + +Only used when `backend` is `podman`, `docker`, or `container`. Ignored on Kubernetes. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `runtime` | string | `"auto"` | Which container runtime to use: `auto` (prefers podman if both are present), `podman`, or `docker`. Only meaningful when `backend: container`; the `podman` and `docker` backend values already pick the runtime directly. | +| `network` | string | `null` | Container network name. `null` uses the runtime's default network. | + ### `resources` -Standard Kubernetes resource requests and limits for HammerDB worker pods. +`limits` applies to both backends: Kubernetes pod resource limits, or `--memory`/`--cpus` on the podman/docker container. `requests` only has a Kubernetes equivalent and is ignored on the container backend. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `requests.memory` | string | `"4Gi"` | Minimum memory reservation. | -| `requests.cpu` | string | `"4"` | Minimum CPU reservation. | -| `limits.memory` | string | `"8Gi"` | Maximum memory limit. | -| `limits.cpu` | string | `"8"` | Maximum CPU limit. | +| `requests.memory` | string | `"4Gi"` | Minimum memory reservation. Kubernetes only. | +| `requests.cpu` | string | `"4"` | Minimum CPU reservation. Kubernetes only. | +| `limits.memory` | string | `"8Gi"` | Maximum memory limit. Both backends. | +| `limits.cpu` | string | `"8"` | Maximum CPU limit. Both backends. | ### `kubernetes` +Only used when `backend` is `kubernetes`. Ignored for `podman`, `docker`, and `container` backends. + | Field | Type | Default | Description | |-------|------|---------|-------------| | `namespace` | string | `"hammerdb"` | Kubernetes namespace for benchmark jobs. | -| `job_ttl` | int | `86400` | Time-to-live in seconds for completed K8s jobs. Default is 24 hours. Min: 0. | +| `job_ttl` | int | `86400` | Time-to-live in seconds for completed jobs. Default is 24 hours. Min: 0. | +| `security_context` | bool | `true` | Emit a restricted-compliant `securityContext` on the job pods. Needed on clusters enforcing the restricted Pod Security Standard. OpenShift applies an equivalent context on its own either way. | +| `use_secrets` | bool | `true` | Pass database credentials through a Kubernetes Secret rather than plain environment values on the Job spec, where `kubectl describe job` would expose them. | ### `storage_metrics` @@ -293,12 +376,21 @@ targets: Ready-to-use config files for every database and benchmark combination: -- [examples/values-oracle.yaml](../examples/values-oracle.yaml) — Oracle reference config (all fields) -- [examples/values-mssql.yaml](../examples/values-mssql.yaml) — MSSQL reference config (all fields) -- [examples/oracle-tprocc.yaml](../examples/oracle-tprocc.yaml) — Oracle TPC-C (4 targets) -- [examples/oracle-tproch.yaml](../examples/oracle-tproch.yaml) — Oracle TPC-H (4 targets) -- [examples/mssql-tprocc.yaml](../examples/mssql-tprocc.yaml) — SQL Server TPC-C (4 targets) -- [examples/mssql-tproch.yaml](../examples/mssql-tproch.yaml) — SQL Server TPC-H (4 targets) +**Kubernetes backend:** + +- [examples/values-oracle.yaml](../examples/values-oracle.yaml): Oracle reference config, all fields +- [examples/values-mssql.yaml](../examples/values-mssql.yaml): MSSQL reference config, all fields +- [examples/oracle-tprocc.yaml](../examples/oracle-tprocc.yaml): Oracle TPC-C, 4 targets +- [examples/oracle-tproch.yaml](../examples/oracle-tproch.yaml): Oracle TPC-H, 4 targets +- [examples/mssql-tprocc.yaml](../examples/mssql-tprocc.yaml): SQL Server TPC-C, 4 targets +- [examples/mssql-tproch.yaml](../examples/mssql-tproch.yaml): SQL Server TPC-H, 4 targets +- [examples/scale-tests/](../examples/scale-tests/): progressive 1 to 8 target configs for Oracle and MSSQL, used to build a scale curve + +**Container backend (podman or docker):** + +- [examples/container-mssql-tprocc.yaml](../examples/container-mssql-tprocc.yaml): SQL Server TPC-C, single host +- [examples/container-postgres-tprocc.yaml](../examples/container-postgres-tprocc.yaml): PostgreSQL TPC-C, single host +- [examples/postgres-setup/provision_postgres.sh](../examples/postgres-setup/provision_postgres.sh): reference script for configuring `pg_hba.conf` and `listen_addresses` so PostgreSQL accepts connections from the worker host ## v1 Config Auto-Migration diff --git a/docs/CONTAINER-IMAGES.md b/docs/CONTAINER-IMAGES.md index 1c373db..f5ac3dc 100644 --- a/docs/CONTAINER-IMAGES.md +++ b/docs/CONTAINER-IMAGES.md @@ -1,12 +1,12 @@ # Container Images -HammerDB-Scale deploys HammerDB as Kubernetes Jobs using pre-built container images. Each Job runs a container that connects to a single database target, executes the benchmark, and outputs results to its logs. +HammerDB-Scale runs one container per database target, using pre-built container images. Each container connects to a single database target, executes the benchmark, and writes its results to log output. Depending on `backend` in your config, that container runs as a plain podman or docker container on a local host, or as a Kubernetes Job. ## Pre-Built Images -| Image | Database | Source | -|-------|----------|--------| -| `sillidata/hammerdb-scale:latest` | SQL Server | [dockerfile](../dockerfile) | +| Image | Covers | Source | +|-------|--------|--------| +| `sillidata/hammerdb-scale:latest` | SQL Server, PostgreSQL | [Dockerfile](../Dockerfile) | | `sillidata/hammerdb-scale-oracle:latest` | Oracle | [Dockerfile.oracle](../Dockerfile.oracle) | These images are hosted on Docker Hub and can be pulled without authentication. @@ -15,40 +15,34 @@ These images are hosted on Docker Hub and can be pulled without authentication. Both images include: -- **Ubuntu 24.04** base -- **HammerDB 5.0** — the benchmark engine -- **Python 3** — for the Pure Storage metrics collector -- **entrypoint.sh** — orchestration script that receives configuration via environment variables, runs HammerDB, and manages metrics collection +- Ubuntu 24.04 base +- HammerDB 6.0, the benchmark engine +- Python 3, for the Pure Storage metrics collector +- `entrypoint.sh`, an orchestration script that receives configuration via environment variables, runs HammerDB, and manages metrics collection -The **MSSQL image** additionally includes: -- Microsoft ODBC Driver 18 for SQL Server -- `mssql-tools18` (including `bcp` for bulk data loading) +The base image (`sillidata/hammerdb-scale`) covers SQL Server and PostgreSQL targets. It includes Microsoft ODBC Driver 18 for SQL Server, `mssql-tools18` (including `bcp` for bulk data loading), and `libpq5` with `postgresql-client` for PostgreSQL. HammerDB bundles Pgtcl but links the system `libpq` at runtime, so `libpq5` has to be present even though HammerDB never calls `psql` directly. -The **Oracle image** extends the MSSQL image and adds: -- Oracle Instant Client 21.11 (Basic + SQL*Plus) -- Required shared libraries for HammerDB's Oratcl interface +The Oracle image extends the base image and adds Oracle Instant Client 21.11 (Basic and SQL*Plus) and the shared libraries HammerDB's Oratcl interface needs. ## Building Your Own Images -### SQL Server Image +The Oracle image extends the base image, and `Dockerfile.oracle` defaults to pulling the published base from Docker Hub rather than the one you just built locally. `hack/build-images.sh` builds both in the correct order, wires the base image explicitly, and verifies the result: ```bash -docker build -f dockerfile -t my-org/hammerdb-scale:latest . +git clone https://github.com/PureStorage-OpenConnect/hammerdb-scale +cd hammerdb-scale +./hack/build-images.sh ``` -### Oracle Image - -The Oracle image extends the base image: +To build manually instead, build the base image first, then pass it to the Oracle build explicitly: ```bash -# Build base first -docker build -f dockerfile -t my-org/hammerdb-scale:latest . - -# Then build Oracle (references base image) -docker build -f Dockerfile.oracle -t my-org/hammerdb-scale-oracle:latest . +docker build -f Dockerfile -t my-org/hammerdb-scale:latest . +docker build -f Dockerfile.oracle -t my-org/hammerdb-scale-oracle:latest \ + --build-arg BASE_IMAGE=my-org/hammerdb-scale:latest . ``` -> **Note:** Building the Oracle image downloads Oracle Instant Client from Oracle's servers. By building and using this image, you accept the [Oracle Technology Network License Agreement](https://www.oracle.com/downloads/licenses/instant-client-lic.html). +Building the Oracle image downloads Oracle Instant Client from Oracle's servers. By building and using this image, you accept the [Oracle Technology Network License Agreement](https://www.oracle.com/downloads/licenses/instant-client-lic.html). ### Using Custom Images @@ -63,7 +57,7 @@ targets: pull_policy: Always ``` -Ensure your Kubernetes cluster can pull from your registry (configure `imagePullSecrets` if needed). +On Kubernetes, make sure the cluster can pull from your registry (configure `imagePullSecrets` if needed). On the container backend, make sure the local podman or docker daemon can pull from it, or has the image already loaded. ## Image Architecture @@ -77,17 +71,17 @@ entrypoint.sh (container startup) └── Writes metadata.json with results and timing ``` -The CLI never connects to the container directly. All configuration passes through environment variables set by the Helm chart, and all results are extracted from pod logs via `kubectl`. +This is the same for both backends. The CLI never connects to the container directly; all configuration passes through environment variables, and results are read back from the container's log output, either with `kubectl logs` on Kubernetes or the container runtime's own log command on the container backend. ## Environment Variables -The Helm chart sets these environment variables on each Job pod. You don't need to set these manually — they're populated from your config file automatically. +These environment variables are set on each container, either by the Helm chart (Kubernetes backend) or directly by the CLI (container backend). You don't need to set them manually; they're populated from your config file automatically. | Variable | Description | |----------|-------------| | `RUN_MODE` | `build` or `load` | | `BENCHMARK` | `tprocc` or `tproch` | -| `DATABASE_TYPE` | `mssql` or `oracle` | +| `DATABASE_TYPE` | `mssql`, `postgres`, or `oracle` | | `HOST` | Database hostname | | `USERNAME` | Database admin user | | `PASSWORD` | Database admin password | @@ -95,4 +89,4 @@ The Helm chart sets these environment variables on each Job pod. You don't need | `TARGET_INDEX` | 0-based index of this target | | `TEST_RUN_ID` | Test run identifier | -Additional database-specific and benchmark-specific variables are documented in the [Helm template](../templates/job-hammerdb-worker.yaml). +Additional database-specific and benchmark-specific variables are documented in the [Helm template](../templates/job-hammerdb-worker.yaml), which both backends draw from for the variable set. diff --git a/docs/MIGRATION.md b/docs/MIGRATION.md index fcad3a0..e301c67 100644 --- a/docs/MIGRATION.md +++ b/docs/MIGRATION.md @@ -6,6 +6,8 @@ pip install hammerdb-scale ``` +v1.x only ran on Kubernetes, and everything below describes that same path in v2: `helm uninstall`, Job cleanup, and the rest. If you'd rather run against a single host with podman or docker instead, most of the command mapping below still applies; the difference is `backend: podman` (or `docker`) in your config instead of `backend: kubernetes` (or omitting it), and no Helm release or cluster involved. See [CONFIGURATION.md](CONFIGURATION.md) for the container-backend config shape. + ## Migrate Config ```bash diff --git a/docs/SECURITY.md b/docs/SECURITY.md index 71b9be0..a32d9d6 100644 --- a/docs/SECURITY.md +++ b/docs/SECURITY.md @@ -2,34 +2,46 @@ ## Credentials -HammerDB-Scale v2.0.0 stores database credentials in plaintext YAML config files. This is consistent with Helm values.yaml patterns but should be handled carefully. +HammerDB-Scale stores database credentials in plaintext YAML config files. This is consistent with Helm values.yaml patterns but should be handled carefully. -**Recommendations:** +Recommendations: -- Do not commit config files containing passwords to version control -- Use file permissions to restrict access to config files (`chmod 600`) -- Consider using environment variable substitution in CI/CD pipelines +- Do not commit config files containing passwords to version control. +- Use file permissions to restrict access to config files (`chmod 600`). +- Consider using environment variable substitution in CI/CD pipelines. + +On the Kubernetes backend, credentials also pass through a Kubernetes Secret by default (`kubernetes.use_secrets: true`), so they do not appear in plain environment values on the Job spec, where `kubectl describe job` would expose them. Set `use_secrets: false` only if your cluster policy requires it; there is no equivalent protection on the container backend, since podman and docker containers run as plain local processes. ## Database Connectivity -- MSSQL connections use encrypted connections by default (`encrypt_connection: true`) -- Oracle connections use `oracledb` thin mode (no Oracle client required) -- The `validate` command tests connectivity as the admin user only, not schema users +- MSSQL connections use encrypted connections by default (`encrypt_connection: true`). +- The `validate` and `clean` commands connect to Oracle using `oracledb` in thin mode, so they need no Oracle client installed on the machine running the CLI. The benchmark itself is a separate matter: HammerDB's Oratcl interface links against Oracle Instant Client's native libraries, which is why the Oracle container image bundles Instant Client even though the CLI does not need it locally. +- The `validate` command tests connectivity as the admin user only, not the schema users the benchmark itself connects as. ## Container Images -- Default images are pulled from `sillidata/hammerdb-scale` (MSSQL) and `sillidata/hammerdb-scale-oracle` (Oracle) -- Images embed HammerDB 5.0 and database client libraries -- Use `pull_policy: Always` in production to ensure latest patches +- Default images are pulled from `sillidata/hammerdb-scale` (SQL Server, PostgreSQL) and `sillidata/hammerdb-scale-oracle` (Oracle). +- Images embed HammerDB 6.0 and database client libraries. +- Use `pull_policy: Always` in production to ensure the latest patches, on Kubernetes. The container backend pulls once and reuses the local image unless you remove it. ## Kubernetes -- Jobs run in the configured namespace (default: `hammerdb`) -- Resource limits are enforced via the `resources` config section -- Jobs have a configurable TTL (`job_ttl`) after which K8s garbage-collects them +Applies only when `backend: kubernetes`. + +- Jobs run in the configured namespace (default: `hammerdb`). +- Resource limits are enforced via the `resources` config section. +- Jobs have a configurable TTL (`job_ttl`) after which Kubernetes garbage-collects them. + +## Container Backend + +Applies only when `backend` is `podman`, `docker`, or `container`. + +- Containers run as plain local processes under whichever user runs the CLI, with no cluster boundary between them and the rest of that host. +- Credentials pass through environment variables on the container directly. Anyone able to run `podman inspect` or `docker inspect` against a running or recently-stopped container on that host can read them. +- This backend is meant for a trusted single host close to the databases, not for multi-tenant or shared infrastructure. ## Network -- The CLI communicates with databases directly during `validate --connectivity` -- Helm and kubectl commands use the current kubeconfig context -- Pure Storage API calls (if enabled) use the configured API token and endpoint +- The CLI communicates with databases directly during `validate` and `clean` operations. +- On the Kubernetes backend, Helm and kubectl commands use the current kubeconfig context. `validate --from-cluster` checks database reachability from where the workers actually run, which is not always the same as reachable from your workstation. +- Pure Storage API calls, if enabled, use the configured API token and endpoint. diff --git a/docs/USAGE-GUIDE.md b/docs/USAGE-GUIDE.md index 2981cba..9c9e908 100644 --- a/docs/USAGE-GUIDE.md +++ b/docs/USAGE-GUIDE.md @@ -8,10 +8,10 @@ pip install hammerdb-scale This installs the `hammerdb-scale` CLI globally, including the bundled Helm chart. You can run it from any directory. -Requires: -- **Python 3.10+** -- **Helm 3.x** and **kubectl** installed and on your `PATH` -- A Kubernetes cluster accessible via your current `kubectl` context +Requires Python 3.10 or later, and one of the two backends: + +- Container backend (recommended): podman or docker on the machine that will run the workers. No cluster, Helm, or kubectl needed. +- Kubernetes backend: Helm 3.x and kubectl on your `PATH`, plus a cluster accessible via your current `kubectl` context. Set `backend: kubernetes` in your config to use this path; see [CONFIGURATION.md](CONFIGURATION.md#running-on-kubernetes) for the full requirements. For isolated installations, use [pipx](https://pipx.pypa.io/): @@ -50,7 +50,7 @@ hammerdb-scale report --open ### `hammerdb-scale version` -Prints CLI, Python, helm, and kubectl versions along with the current Kubernetes context. +Prints the CLI and Python versions, plus whichever backend tooling it finds installed: Helm and kubectl with the current Kubernetes context, and podman or docker if present. ### `hammerdb-scale init` @@ -73,29 +73,22 @@ hammerdb-scale init -i The wizard walks through 6 steps: -1. **Deployment** — Name your benchmark -2. **Database & Benchmark** — Select Oracle or SQL Server, TPC-C or TPC-H -3. **Database Targets** — Enter hostnames/IPs (with auto-generated names like `db-01`) -4. **Credentials** — Database username and password (Oracle: service name, schema password) -5. **Benchmark Parameters** — Warehouses (TPC-C) or scale factor (TPC-H) -6. **Infrastructure** — Kubernetes namespace, Pure Storage metrics +1. **Deployment**: name your benchmark and choose where the workers run (podman, docker, or Kubernetes; it leads with whatever runtime it finds installed) +2. **Database & Benchmark**: select Oracle, SQL Server, or PostgreSQL, and TPC-C or TPC-H +3. **Database Targets**: enter hostnames or IPs, with auto-generated names like `db-01` +4. **Credentials**: database username and password, plus Oracle's service name and schema password where relevant +5. **Benchmark Parameters**: warehouses for TPC-C, or scale factor for TPC-H +6. **Infrastructure**: Kubernetes namespace, only asked if you chose that backend, and Pure Storage metrics -After the core steps, an optional **Advanced Options** prompt lets you configure virtual users, rampup/duration, and pod resources. If declined, sensible defaults are used. +After the core steps, an optional Advanced Options prompt lets you configure virtual users, rampup and duration, and (on Kubernetes) pod resources. If declined, sensible defaults are used. -A **Configuration Summary** table is shown before writing, giving you a chance to review all values (passwords masked) and confirm or cancel. +A Configuration Summary table is shown before writing, giving you a chance to review all values, with passwords masked, and confirm or cancel. -Both `init` and `init -i` produce identical YAML output — the wizard is purely a UX enhancement for the input experience. +Both `init` and `init -i` produce identical YAML output. The wizard is purely a UX enhancement for the input experience. ### `hammerdb-scale validate` -Validates configuration through 6 layers: - -1. YAML syntax -2. Schema validation (Pydantic) -3. Image/database type consistency -4. Tool prerequisites (helm, kubectl) -5. Kubernetes cluster access -6. Database connectivity (actual login test) +Validates configuration through several layers: YAML syntax, schema validation, image and database type consistency, and database connectivity, an actual login test against every target. The remaining layers depend on `backend`. On Kubernetes, it also checks that Helm and kubectl are installed and that the current context can create Jobs, ConfigMaps, and Secrets in the target namespace. On the container backend, it checks that podman or docker is installed instead, and there is no cluster-access layer. ```bash hammerdb-scale validate # Full validation @@ -105,13 +98,13 @@ hammerdb-scale validate -f config.yaml # Explicit config ### `hammerdb-scale build` -Creates benchmark schema (tables, indexes) on all database targets. Deploys one Kubernetes Job per target. +Creates benchmark schema (tables, indexes) on all database targets. Starts one worker per target, either a local container or a Kubernetes Job depending on `backend`. ```bash hammerdb-scale build --benchmark tprocc hammerdb-scale build --benchmark tprocc --id my-test-001 hammerdb-scale build --wait # Poll until all jobs complete -hammerdb-scale build --dry-run # Render Helm templates only +hammerdb-scale build --dry-run # Render without deploying ``` `--benchmark` is optional if `default_benchmark` is set in your config. @@ -153,7 +146,7 @@ hammerdb-scale logs --tail 50 # Last 50 lines ### `hammerdb-scale results` -Aggregates results from K8s job logs, parses benchmark metrics, and saves to a local directory. +Aggregates results from worker logs, parses benchmark metrics, and saves to a local directory. ```bash hammerdb-scale results @@ -163,7 +156,7 @@ hammerdb-scale results --json # Machine-readable output ### `hammerdb-scale report` -Generates a self-contained HTML scorecard with charts and tables. The file can be opened in any browser, shared, or viewed offline — all CSS and JavaScript are embedded. +Generates a self-contained HTML scorecard with charts and tables. All CSS and JavaScript are embedded, so the file can be opened in any browser, shared, or viewed offline. ```bash hammerdb-scale report # Default: results/{test-id}/scorecard.html @@ -176,7 +169,7 @@ hammerdb-scale report -o report.html # Custom output path Removes benchmark resources. ```bash -# Remove K8s resources (Helm releases, jobs) +# Remove workers: containers on the container backend, Helm releases and jobs on Kubernetes hammerdb-scale clean --resources --id hammerdb-scale clean --resources --everything @@ -211,9 +204,9 @@ hammerdb-scale clean --resources --everything --force ### What to Expect -- TPM/NOPM scale roughly linearly with the number of database targets when storage is not the bottleneck -- When storage saturates, adding more databases will show diminishing returns — this is the point of scale testing -- Per-target metrics should be roughly equal. Large variance suggests a configuration or resource imbalance +- TPM/NOPM scale roughly linearly with the number of database targets when storage is not the bottleneck. +- When storage saturates, adding more databases shows diminishing returns. That is the point of scale testing: finding where the curve bends. +- Per-target metrics should be roughly equal. Large variance suggests a configuration or resource imbalance. ### Output Directory @@ -229,25 +222,18 @@ results/my-benchmark-20250304-1200/ └── scorecard.html # HTML report (after running `report`) ``` -- **summary.json** — Machine-readable results. Contains per-target metrics, aggregate totals, and the config snapshot used for the test. -- **Target logs** — Raw HammerDB output. Useful for debugging failed targets or verifying benchmark parameters. -- **pure_metrics.json** — Time-series storage metrics from Pure Storage FlashArray (IOPS, latency, bandwidth). -- **scorecard.html** — Self-contained HTML report. Can be opened in any browser without a web server. +- **summary.json**: machine-readable results, containing per-target metrics, aggregate totals, and the config snapshot used for the test. +- **Target logs**: raw HammerDB output, useful for debugging failed targets or verifying benchmark parameters. +- **pure_metrics.json**: time-series storage metrics from the Pure Storage FlashArray (IOPS, latency, bandwidth). +- **scorecard.html**: self-contained HTML report that opens in any browser without a web server. ### Reading the Scorecard -The HTML scorecard contains: +The TPC-C scorecard opens with summary cards for total TPM, total NOPM, and the average TPM and NOPM per target, followed by a per-target table showing status, duration, TPM, and NOPM for each database. Distribution charts show TPM and NOPM per target as horizontal bars: even bars indicate balanced load, uneven bars suggest an issue with specific targets. -**TPC-C Scorecard:** -- **Summary cards** — Total TPM, Total NOPM, Average TPM/target, Average NOPM/target -- **Per-target table** — Status, duration, TPM, and NOPM for each database target -- **Distribution charts** — Horizontal bar charts showing TPM and NOPM per target. Even bars indicate balanced load; uneven bars suggest an issue with specific targets. -- **Storage performance** (if Pure Storage metrics enabled) — Latency, IOPS, and bandwidth time-series charts showing how the storage array performed during the benchmark +With `time_profile` enabled, which is the default, the scorecard also includes a transaction response time section: p50, p95, and p99 latency per transaction type, drawn from HammerDB's own reservoir sampling rather than bucketed estimates. If Pure Storage metrics are enabled, a storage performance section shows latency, IOPS, and bandwidth as time series over the course of the benchmark. -**TPC-H Scorecard:** -- **QphH summary** — Composite query throughput metric -- **Per-query timing table** — Execution time for each of the 22 TPC-H queries (avg, min, max across targets) -- **Query distribution chart** — Visual comparison of query execution times +The TPC-H scorecard leads with the QphH summary, the composite query throughput metric, followed by a per-query timing table with average, minimum, and maximum execution time for each of the 22 TPC-H queries across targets, and a chart comparing query execution times. ## Pure Storage Metrics @@ -271,19 +257,13 @@ storage_metrics: ### What's Collected -- **Read/Write IOPS** — I/O operations per second -- **Read/Write Latency** — Response time in microseconds (average, P95, P99) -- **Read/Write Bandwidth** — Data throughput in bytes per second -- **Queue Depth** — Outstanding I/O requests +It collects read and write IOPS, read and write latency (average, P95, P99, in microseconds), read and write bandwidth (bytes per second), and queue depth (outstanding I/O requests). -Metrics are collected from the first target pod only (to avoid duplicate API calls) and run as a background process for the duration of the benchmark. +Metrics come from the first target's worker only, to avoid duplicate API calls, and run as a background process for the duration of the benchmark. ### In the Report -The storage performance section of the scorecard shows: -- Summary cards with peak and average values -- Time-series line charts for latency, IOPS, and bandwidth over the duration of the test -- These charts help identify storage bottlenecks — look for latency spikes or IOPS plateaus that correlate with benchmark load +The storage performance section of the scorecard shows summary cards with peak and average values, plus time-series line charts for latency, IOPS, and bandwidth over the duration of the test. Look for latency spikes or IOPS plateaus that correlate with benchmark load; those are where storage is the bottleneck rather than the database or the driver. ## Exit Codes @@ -304,9 +284,17 @@ The storage performance section of the scorecard shows: ### `helm not found` or `kubectl not found` -Install [Helm](https://helm.sh/docs/intro/install/) and [kubectl](https://kubernetes.io/docs/tasks/tools/), then ensure they're on your `PATH`. Run `hammerdb-scale version` to verify. +These only matter on the Kubernetes backend. If you meant to use the container backend, check that `backend` in your config is set to `podman`, `docker`, or `container`, not left as the `kubernetes` default. Otherwise, install [Helm](https://helm.sh/docs/intro/install/) and [kubectl](https://kubernetes.io/docs/tasks/tools/), then ensure they're on your `PATH`. Run `hammerdb-scale version` to verify. + +### No container runtime found + +This is the container-backend equivalent of the error above. Install podman or docker, or switch `backend` to `kubernetes` if that's what you meant to use. + +### SELinux denies the script mount (container backend) + +On a host running SELinux, podman needs a `:z` relabel on the read-only script mount, which the CLI applies automatically when it detects SELinux is active. If detection is wrong for your setup, the container fails immediately with a permission-denied mount error. Confirm SELinux mode with `getenforce`, and file an issue if the automatic handling did not match it. -### Namespace doesn't exist +### Namespace doesn't exist (Kubernetes backend) Create the namespace before running: @@ -318,12 +306,12 @@ Or change the namespace in your config (`kubernetes.namespace`). ### Database connectivity failures during `validate` -- Verify the database host is reachable from your workstation: `telnet ` -- Check credentials are correct -- For Oracle: ensure the listener is running and the service name matches your config -- For MSSQL: if using `encrypt_connection: true`, the server must support TLS +- Verify the database host is reachable from where the workers actually run. From your workstation, `telnet ` is a quick check, but on Kubernetes the workers may sit on a different network path; use `hammerdb-scale validate --from-cluster` to check from there instead. +- Check credentials are correct. +- For Oracle, ensure the listener is running and the service name matches your config. +- For MSSQL, if using `encrypt_connection: true`, the server must support TLS. -### Jobs stuck in Pending +### Jobs stuck in Pending (Kubernetes backend) Check pod events: @@ -331,9 +319,7 @@ Check pod events: kubectl describe pod -n hammerdb -l hammerdb.io/test-id= ``` -Common causes: -- **ImagePullBackOff** — Container image can't be pulled. Check image name and registry access. -- **Insufficient resources** — Reduce `resources.requests` in your config or ensure your cluster has available capacity. +Common causes: `ImagePullBackOff`, meaning the container image can't be pulled, so check the image name and registry access; or insufficient resources, in which case reduce `resources.requests` in your config or free up capacity on the cluster. ### Build jobs fail @@ -341,21 +327,17 @@ Common causes: hammerdb-scale logs --id ``` -Common causes: -- Wrong credentials (check `targets.defaults.username`/`password`) -- Database not reachable from inside the Kubernetes cluster (different from your workstation) -- For Oracle: tablespace doesn't exist — create it before building -- For MSSQL: database name conflict — use `clean --database` first +Common causes: wrong credentials, so check `targets.defaults.username` and `password`; the database not reachable from where the worker actually runs, which on Kubernetes can differ from your workstation; for Oracle, a tablespace that doesn't exist, which needs creating before building; for MSSQL, a database name conflict, which `clean --database` resolves. ### Results show 0 TPM -- Ensure `driver: timed` (not `test`) in your TPC-C config -- Ensure `duration` is long enough (at least 5 minutes recommended) -- Check logs for errors: `hammerdb-scale logs --target ` +- Ensure `driver: timed`, not `test`, in your TPC-C config. +- Ensure `duration` is long enough. Five minutes or more is a reasonable minimum. +- Check logs for errors: `hammerdb-scale logs --target `. ### Partial failures (exit code 2) -Some targets completed but others failed. Run `hammerdb-scale results` to see which targets succeeded. Check logs for failed targets individually: +Some targets completed but others failed. Run `hammerdb-scale results` to see which targets succeeded, then check logs for the failed ones individually: ```bash hammerdb-scale logs --target diff --git a/entrypoint.sh b/entrypoint.sh index f7f0808..9d8c216 100644 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -14,6 +14,55 @@ log() { # Set TMPDIR if not already set export TMPDIR="${TMPDIR:-/tmp}" +# Resolve the HammerDB installation directory. +# +# HAMMERDB_HOME is set by the Dockerfile so the version lives in exactly one +# place. The glob fallback keeps this script working in images built before +# that variable existed, and in any image where HammerDB was installed to a +# differently-versioned path. +if [ -z "$HAMMERDB_HOME" ]; then + for candidate in /opt/HammerDB-*; do + if [ -x "$candidate/hammerdbcli" ]; then + HAMMERDB_HOME="$candidate" + break + fi + done +fi + +if [ -z "$HAMMERDB_HOME" ] || [ ! -x "$HAMMERDB_HOME/hammerdbcli" ]; then + log "ERROR: Could not locate a HammerDB installation." + log " Set HAMMERDB_HOME, or install HammerDB under /opt/HammerDB-." + exit 1 +fi +export HAMMERDB_HOME + +# Resolve the TCL script directory independently of HAMMERDB_HOME. +# +# The scripts are mounted in by the orchestrator (a ConfigMap on Kubernetes, a +# bind mount for containers), and the mount path is chosen by the chart while +# HAMMERDB_HOME is baked into the image. Those two can disagree: a 6.0-aware +# chart mounting into a 5.0 image lands the scripts somewhere this script would +# never look, and the pod fails with a confusing "script not found". +# +# Rather than requiring exact agreement, search the candidate locations. This +# keeps any chart version working against any image version. +if [ -n "$HAMMERDB_SCRIPT_DIR" ]; then + SCRIPT_DIR="$HAMMERDB_SCRIPT_DIR" +else + SCRIPT_DIR="" + for candidate in "$HAMMERDB_HOME/scripts" /opt/HammerDB-*/scripts /scripts; do + # A directory only counts if it holds mounted TCL scripts. + if compgen -G "$candidate/*.tcl" > /dev/null 2>&1; then + SCRIPT_DIR="$candidate" + break + fi + done + # Fall back to the conventional path so the error message below is sensible. + SCRIPT_DIR="${SCRIPT_DIR:-$HAMMERDB_HOME/scripts}" +fi +log "Using HammerDB at: $HAMMERDB_HOME" +log "Using scripts from: $SCRIPT_DIR" + # Ensure all required environment variables are set if [ -z "$USERNAME" ] || [ -z "$PASSWORD" ] || [ -z "$HOST" ] || [ -z "$BENCHMARK" ]; then log "ERROR: Environment variables USERNAME, PASSWORD, HOST and BENCHMARK must be set." @@ -50,13 +99,12 @@ case "$DATABASE_TYPE" in ;; postgres) log "Database: PostgreSQL" - log "ERROR: PostgreSQL support not yet implemented" - exit 1 ;; oracle) log "Database: Oracle" - # Check if Oracle Instant Client is installed - if [ ! -d "/opt/oracle/instantclient_21_11" ]; then + # Check if Oracle Instant Client is installed. Match any version so a + # client upgrade does not require editing this script. + if ! compgen -G "/opt/oracle/instantclient_*" > /dev/null; then log "" log "============================================================" log "ERROR: Oracle Instant Client not found!" @@ -177,25 +225,43 @@ elif [[ "$DATABASE_TYPE" == "oracle" ]]; then log "ERROR: Unknown BENCHMARK: '$BENCHMARK'. Supported: tprocc, tproch" exit 1 fi -# EXTENSION POINT: Add PostgreSQL script selection here -# elif [[ "$DATABASE_TYPE" == "postgres" ]]; then -# if [[ "$BENCHMARK" == "tprocc" ]]; then -# case "$RUN_MODE" in -# build) -# SCRIPT_NAME="build_schema_tprocc_pg.tcl" -# ;; -# load) -# SCRIPT_NAME="load_test_tprocc_pg.tcl" -# ;; -# parse) -# SCRIPT_NAME="parse_output_tprocc_pg.tcl" -# ;; -# esac -# fi - -# EXTENSION POINT: Add Oracle script selection here -# elif [[ "$DATABASE_TYPE" == "oracle" ]]; then -# ... +elif [[ "$DATABASE_TYPE" == "postgres" ]]; then + if [[ "$BENCHMARK" == "tprocc" ]]; then + case "$RUN_MODE" in + build) + SCRIPT_NAME="build_schema_tprocc.tcl" + ;; + load) + SCRIPT_NAME="load_test_tprocc.tcl" + ;; + parse) + SCRIPT_NAME="parse_output_tprocc.tcl" + ;; + *) + log "ERROR: Unknown RUN_MODE: '$RUN_MODE' for benchmark '$BENCHMARK'" + exit 1 + ;; + esac + elif [[ "$BENCHMARK" == "tproch" ]]; then + case "$RUN_MODE" in + build) + SCRIPT_NAME="build_schema_tproch.tcl" + ;; + load) + SCRIPT_NAME="load_test_tproch.tcl" + ;; + parse) + SCRIPT_NAME="parse_output_tproch.tcl" + ;; + *) + log "ERROR: Unknown RUN_MODE: '$RUN_MODE' for benchmark '$BENCHMARK'" + exit 1 + ;; + esac + else + log "ERROR: Unknown BENCHMARK: '$BENCHMARK'. Supported: tprocc, tproch" + exit 1 + fi # EXTENSION POINT: Add MySQL script selection here # elif [[ "$DATABASE_TYPE" == "mysql" ]]; then @@ -207,8 +273,8 @@ else fi # Check if the script exists -if [ ! -f "/opt/HammerDB-5.0/scripts/$SCRIPT_NAME" ]; then - log "ERROR: Script '/opt/HammerDB-5.0/scripts/$SCRIPT_NAME' not found." +if [ ! -f "$SCRIPT_DIR/$SCRIPT_NAME" ]; then + log "ERROR: Script '$SCRIPT_DIR/$SCRIPT_NAME' not found." exit 1 fi @@ -260,7 +326,7 @@ if [[ "$RUN_MODE" == "load" ]] && [[ "${PURE_ENABLED:-false}" == "true" ]]; then fi # Check if Python script exists - if [ -f "/opt/HammerDB-5.0/scripts/collect_pure_metrics.py" ]; then + if [ -f "$SCRIPT_DIR/collect_pure_metrics.py" ]; then log "Starting Pure Storage metrics collector" log " Array: ${PURE_HOST}" log " Duration: ${COLLECTION_DURATION}s" @@ -283,7 +349,7 @@ if [[ "$RUN_MODE" == "load" ]] && [[ "${PURE_ENABLED:-false}" == "true" ]]; then log "Using Python interpreter: $PYTHON_CMD" # Start collector in background with all parameters from environment - $PYTHON_CMD /opt/HammerDB-5.0/scripts/collect_pure_metrics.py \ + $PYTHON_CMD $SCRIPT_DIR/collect_pure_metrics.py \ --host "${PURE_HOST}" \ --token "${PURE_API_TOKEN}" \ --duration "$COLLECTION_DURATION" \ @@ -297,7 +363,7 @@ if [[ "$RUN_MODE" == "load" ]] && [[ "${PURE_ENABLED:-false}" == "true" ]]; then log "Pure Storage collector started with PID: $PURE_PID" fi else - log "WARNING: Pure Storage metrics script not found at /opt/HammerDB-5.0/scripts/collect_pure_metrics.py" + log "WARNING: Pure Storage metrics script not found at $SCRIPT_DIR/collect_pure_metrics.py" fi else log "Pure Storage monitoring enabled but this pod is NOT the designated collector" @@ -309,7 +375,7 @@ fi # Run the specified HammerDB script with unbuffered output log "Test started at: $START_TIME" EXIT_CODE=0 -/opt/HammerDB-5.0/hammerdbcli auto /opt/HammerDB-5.0/scripts/$SCRIPT_NAME 2>&1 || EXIT_CODE=$? +"$HAMMERDB_HOME/hammerdbcli" auto "$SCRIPT_DIR/$SCRIPT_NAME" 2>&1 || EXIT_CODE=$? # Auto-invoke parse phase after load tests complete successfully if [[ "$RUN_MODE" == "load" ]] && [ $EXIT_CODE -eq 0 ]; then @@ -322,13 +388,13 @@ if [[ "$RUN_MODE" == "load" ]] && [ $EXIT_CODE -eq 0 ]; then fi if [ -n "$PARSE_SCRIPT" ]; then - if [ -f "/opt/HammerDB-5.0/scripts/$PARSE_SCRIPT" ]; then + if [ -f "$SCRIPT_DIR/$PARSE_SCRIPT" ]; then log "Executing parse script: $PARSE_SCRIPT" - /opt/HammerDB-5.0/hammerdbcli auto /opt/HammerDB-5.0/scripts/$PARSE_SCRIPT 2>&1 || { + "$HAMMERDB_HOME/hammerdbcli" auto "$SCRIPT_DIR/$PARSE_SCRIPT" 2>&1 || { log "WARNING: Parse script failed but continuing (exit code: $?)" } else - log "WARNING: Parse script not found at /opt/HammerDB-5.0/scripts/$PARSE_SCRIPT" + log "WARNING: Parse script not found at $SCRIPT_DIR/$PARSE_SCRIPT" fi fi fi diff --git a/examples/container-mssql-tprocc.yaml b/examples/container-mssql-tprocc.yaml new file mode 100644 index 0000000..0d02d3b --- /dev/null +++ b/examples/container-mssql-tprocc.yaml @@ -0,0 +1,82 @@ +# ============================================================================ +# SQL Server TPC-C, run from a single host with podman or docker +# ============================================================================ +# The lowest-friction way to use this tool: no Kubernetes, no Helm, no +# kubectl. One container per database target, all on the machine you run +# the CLI from. +# +# hammerdb-scale validate -c examples/container-mssql-tprocc.yaml +# hammerdb-scale run --build --wait -c examples/container-mssql-tprocc.yaml +# hammerdb-scale report --open -c examples/container-mssql-tprocc.yaml +# +# Put this host close to the databases. Driver-to-database latency counts +# against your numbers, so a laptop over a VPN will understate your storage. +# ============================================================================ + +name: mssql-storage-validation +description: "SQL Server TPC-C across 4 targets" +default_benchmark: tprocc +backend: podman # podman|docker|kubernetes + +container: + runtime: auto # auto picks podman, then docker + +targets: + defaults: + type: mssql + username: sa + password: "CHANGE-ME" + mssql: + port: 1433 + tprocc: + database_name: tpcc # created during the build phase + use_bcp: false # bulk copy load, faster but noisier + connection: + encrypt_connection: true + trust_server_cert: true # accept self-signed certs + + hosts: + - name: sql-01 + host: "10.0.0.11" + - name: sql-02 + host: "10.0.0.12" + - name: sql-03 + host: "10.0.0.13" + - name: sql-04 + host: "10.0.0.14" + +hammerdb: + tprocc: + # Warehouses drive the dataset size and therefore the build time. + # 100 builds in minutes; 10,000 takes hours and is roughly 1 TB per + # target. Build once, then run as many benchmarks as you like. + warehouses: 1000 + build_virtual_users: 8 # parallelism during the build phase + load_virtual_users: 8 # concurrent users during the run + driver: timed + rampup: 2 # minutes of warm-up, not measured + duration: 10 # minutes of measured workload + all_warehouses: true + checkpoint: false + time_profile: true # per-transaction response percentiles + +# Resource limits apply to both backends: --memory/--cpus for containers, +# pod requests and limits on Kubernetes. A worker is light, so these are +# generous; measured usage is around 57 MB and a fifth of a core. +resources: + limits: + memory: "2Gi" + cpu: "2" + +# ============================================================================ +# Optional: collect FlashArray metrics alongside the benchmark so array +# latency can be read against the database's own response times. +# ============================================================================ +storage_metrics: + enabled: false + # provider: pure + # pure: + # host: "" # FlashArray management IP + # api_token: "" # Settings > Users > API Tokens + # poll_interval: 5 + # verify_ssl: false diff --git a/examples/container-postgres-tprocc.yaml b/examples/container-postgres-tprocc.yaml new file mode 100644 index 0000000..c9f2f5a --- /dev/null +++ b/examples/container-postgres-tprocc.yaml @@ -0,0 +1,68 @@ +# ============================================================================ +# PostgreSQL TPC-C, run from a single host with podman or docker +# ============================================================================ +# hammerdb-scale validate -c examples/container-postgres-tprocc.yaml +# hammerdb-scale run --build --wait -c examples/container-postgres-tprocc.yaml +# hammerdb-scale report --open -c examples/container-postgres-tprocc.yaml +# +# The PostgreSQL hosts need to accept connections from wherever the workers +# run: an entry in pg_hba.conf and listen_addresses set accordingly. See +# examples/postgres-setup/provision_postgres.sh for a tuned setup. +# ============================================================================ + +name: postgres-storage-validation +description: "PostgreSQL TPC-C across 4 targets" +default_benchmark: tprocc +backend: podman + +container: + runtime: auto + +targets: + defaults: + type: postgres + username: postgres # superuser, used to create the schema + password: "CHANGE-ME" + postgres: + port: 5432 + sslmode: prefer # prefer|require|disable + tprocc: + database_name: tpcc # created during the build phase + user: tpcc # schema owner, created during build + password: tpcc + # Stored procedures must match between build and run: the driver + # calls procedures that only exist if the schema was built with + # them. Change this before building, not after. + stored_procedures: true + partition: false + vacuum: false + + hosts: + - name: pg-01 + host: "10.0.0.21" + - name: pg-02 + host: "10.0.0.22" + - name: pg-03 + host: "10.0.0.23" + - name: pg-04 + host: "10.0.0.24" + +hammerdb: + tprocc: + warehouses: 1000 + build_virtual_users: 8 + load_virtual_users: 8 + driver: timed + rampup: 2 + duration: 10 + all_warehouses: true + checkpoint: false + time_profile: true + +resources: + limits: + memory: "2Gi" + cpu: "2" + +storage_metrics: + enabled: false diff --git a/examples/postgres-setup/provision_postgres.sh b/examples/postgres-setup/provision_postgres.sh new file mode 100755 index 0000000..5052b8c --- /dev/null +++ b/examples/postgres-setup/provision_postgres.sh @@ -0,0 +1,197 @@ +#!/bin/bash +# Provision a PostgreSQL benchmark host for HammerDB TPROC-C / TPROC-H. +# +# Idempotent: safe to re-run. With --force it wipes any existing cluster and +# rebuilds from scratch, which is what makes a fleet provably identical rather +# than "whatever was there before". +# +# Target hardware (the PostgreSQL-Bench fleet): +# RHEL 9.x, 48 cores, 125GB RAM, dedicated volume mounted at /var/lib/pgsql +# +# Usage: +# ./provision_postgres.sh [--force] [--version 18] [--password PW] + +set -euo pipefail + +PG_VERSION="${PG_VERSION:-18}" +PG_PASSWORD="${PG_PASSWORD:-Osmium76}" +FORCE=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + --force) FORCE=1; shift ;; + --version) PG_VERSION="$2"; shift 2 ;; + --password) PG_PASSWORD="$2"; shift 2 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +PGBIN="/usr/pgsql-${PG_VERSION}/bin" +PGDATA="/var/lib/pgsql/${PG_VERSION}/data" +SERVICE="postgresql-${PG_VERSION}" + +log() { echo "[$(date +'%H:%M:%S')] $*"; } + +# --- Sizing, derived from the actual host --------------------------------- +TOTAL_MB=$(free -m | awk '/^Mem:/{print $2}') +CPUS=$(nproc) + +# PostgreSQL guidance: shared_buffers ~25% of RAM, effective_cache_size ~75%. +SHARED_BUFFERS_MB=$(( TOTAL_MB / 4 )) +EFFECTIVE_CACHE_MB=$(( TOTAL_MB * 3 / 4 )) +MAINTENANCE_WORK_MEM_MB=2048 +# TPROC-C is short OLTP transactions, so work_mem stays modest; a large value +# here multiplies per sort node across hundreds of connections. +WORK_MEM_MB=64 + +MAX_PARALLEL=$(( CPUS * 5 / 6 )) +PARALLEL_PER_GATHER=$(( CPUS / 6 )) +[ "$PARALLEL_PER_GATHER" -lt 2 ] && PARALLEL_PER_GATHER=2 + +log "Host: ${CPUS} cores, ${TOTAL_MB}MB RAM" +log "Sizing: shared_buffers=${SHARED_BUFFERS_MB}MB effective_cache_size=${EFFECTIVE_CACHE_MB}MB" + +# --- Install --------------------------------------------------------------- +if ! rpm -q "postgresql${PG_VERSION}-server" >/dev/null 2>&1; then + log "Installing PostgreSQL ${PG_VERSION} from PGDG" + dnf install -y \ + "https://download.postgresql.org/pub/repos/yum/reporpms/EL-9-x86_64/pgdg-redhat-repo-latest.noarch.rpm" \ + >/dev/null 2>&1 || true + # The PGDG repo ships its own libpq; RHEL's AppStream module would shadow it. + dnf -qy module disable postgresql >/dev/null 2>&1 || true + dnf install -y \ + "postgresql${PG_VERSION}-server" \ + "postgresql${PG_VERSION}-contrib" >/dev/null + log "Installed $(rpm -q postgresql${PG_VERSION}-server)" +else + log "Already installed: $(rpm -q postgresql${PG_VERSION}-server)" +fi + +# --- Cluster --------------------------------------------------------------- +if [ -s "${PGDATA}/PG_VERSION" ] && [ "$FORCE" -eq 1 ]; then + log "--force: removing existing cluster at ${PGDATA}" + systemctl stop "$SERVICE" 2>/dev/null || true + rm -rf "${PGDATA:?}"/* +fi + +if [ ! -s "${PGDATA}/PG_VERSION" ]; then + log "Initialising cluster" + # Checksums cost a little CPU but make silent storage corruption visible, + # which matters when the point of the exercise is testing storage. + PGSETUP_INITDB_OPTIONS="--data-checksums" \ + "${PGBIN}/postgresql-${PG_VERSION}-setup" initdb >/dev/null +else + log "Cluster already initialised" +fi + +# --- Configuration --------------------------------------------------------- +# Written as a conf.d drop-in rather than ALTER SYSTEM so the settings are +# visible in the repo, reproducible, and easy to diff across the fleet. +log "Writing tuning configuration" +mkdir -p "${PGDATA}/conf.d" +cat > "${PGDATA}/conf.d/hammerdb.conf" <> "${PGDATA}/postgresql.conf" +fi + +# --- Authentication -------------------------------------------------------- +log "Configuring pg_hba.conf for remote benchmark clients" +if ! grep -q "hammerdb-scale" "${PGDATA}/pg_hba.conf"; then + cat >> "${PGDATA}/pg_hba.conf" <<'EOF' + +# hammerdb-scale: allow benchmark clients from the lab network. +host all all 10.0.0.0/8 scram-sha-256 +EOF +fi + +# --- Firewall -------------------------------------------------------------- +# RHEL runs firewalld by default with only ssh open, so PostgreSQL listening on +# 0.0.0.0 is still unreachable from the benchmark clients without this. +if systemctl is-active --quiet firewalld 2>/dev/null; then + if ! firewall-cmd --list-ports 2>/dev/null | grep -q "5432/tcp"; then + log "Opening 5432/tcp in firewalld" + firewall-cmd --permanent --add-port=5432/tcp >/dev/null + firewall-cmd --reload >/dev/null + else + log "firewalld already allows 5432/tcp" + fi +else + log "firewalld inactive; no firewall change needed" +fi + +# --- Start ----------------------------------------------------------------- +log "Starting ${SERVICE}" +systemctl enable "$SERVICE" >/dev/null 2>&1 +systemctl restart "$SERVICE" + +for _ in $(seq 1 30); do + "${PGBIN}/pg_isready" -q && break + sleep 2 +done +"${PGBIN}/pg_isready" >/dev/null || { log "ERROR: server did not become ready"; exit 1; } + +# --- Benchmark role -------------------------------------------------------- +# HammerDB creates the tpcc schema itself but needs a superuser to do so. +log "Ensuring benchmark role exists" +sudo -u postgres "${PGBIN}/psql" -qtAc \ + "DO \$\$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'postgres') THEN + CREATE ROLE postgres SUPERUSER LOGIN; + END IF; + END \$\$;" >/dev/null + +sudo -u postgres "${PGBIN}/psql" -qtAc \ + "ALTER ROLE postgres WITH PASSWORD '${PG_PASSWORD}'" >/dev/null + +log "Ready: $(sudo -u postgres ${PGBIN}/psql -tAc 'select version()' | cut -c1-40)" +log "shared_buffers=$(sudo -u postgres ${PGBIN}/psql -tAc 'show shared_buffers')" diff --git a/examples/scale-tests/mssql-scale-1.yaml b/examples/scale-tests/mssql-scale-1.yaml new file mode 100644 index 0000000..5d9e689 --- /dev/null +++ b/examples/scale-tests/mssql-scale-1.yaml @@ -0,0 +1,64 @@ +# SQL Server TPC-C scale test — 1 target +name: mssql-scale-1 +description: "MSSQL TPROCC benchmark — 1 target" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/examples/scale-tests/mssql-scale-2.yaml b/examples/scale-tests/mssql-scale-2.yaml new file mode 100644 index 0000000..627ff67 --- /dev/null +++ b/examples/scale-tests/mssql-scale-2.yaml @@ -0,0 +1,66 @@ +# SQL Server TPC-C scale test — 2 targets +name: mssql-scale-2 +description: "MSSQL TPROCC benchmark — 2 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/examples/scale-tests/mssql-scale-4.yaml b/examples/scale-tests/mssql-scale-4.yaml new file mode 100644 index 0000000..a54c120 --- /dev/null +++ b/examples/scale-tests/mssql-scale-4.yaml @@ -0,0 +1,70 @@ +# SQL Server TPC-C scale test — 4 targets +name: mssql-scale-4 +description: "MSSQL TPROCC benchmark — 4 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + - name: sql-bench-03 + host: "sql-bench-03.soln.local" + - name: sql-bench-04 + host: "sql-bench-04.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/examples/scale-tests/mssql-scale-6.yaml b/examples/scale-tests/mssql-scale-6.yaml new file mode 100644 index 0000000..054f466 --- /dev/null +++ b/examples/scale-tests/mssql-scale-6.yaml @@ -0,0 +1,74 @@ +# SQL Server TPC-C scale test — 8 targets +name: mssql-scale-8 +description: "MSSQL TPROCC benchmark — 8 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + - name: sql-bench-03 + host: "sql-bench-03.soln.local" + - name: sql-bench-04 + host: "sql-bench-04.soln.local" + - name: sql-bench-05 + host: "sql-bench-05.soln.local" + - name: sql-bench-06 + host: "sql-bench-06.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/examples/scale-tests/mssql-scale-8.yaml b/examples/scale-tests/mssql-scale-8.yaml new file mode 100644 index 0000000..043f844 --- /dev/null +++ b/examples/scale-tests/mssql-scale-8.yaml @@ -0,0 +1,78 @@ +# SQL Server TPC-C scale test — 8 targets +name: mssql-scale-8 +description: "MSSQL TPROCC benchmark — 8 targets" +default_benchmark: tprocc + +targets: + defaults: + type: mssql + username: sa + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale + tag: latest + pull_policy: Always + mssql: + port: 1433 + connection: + tcp: true + authentication: sql + odbc_driver: "ODBC Driver 18 for SQL Server" + encrypt_connection: true + trust_server_cert: true + tprocc: + database_name: tpcc + tproch: + database_name: tpch + + hosts: + - name: sql-bench-01 + host: "sql-bench-01.soln.local" + - name: sql-bench-02 + host: "sql-bench-02.soln.local" + - name: sql-bench-03 + host: "sql-bench-03.soln.local" + - name: sql-bench-04 + host: "sql-bench-04.soln.local" + - name: sql-bench-05 + host: "sql-bench-05.soln.local" + - name: sql-bench-06 + host: "sql-bench-06.soln.local" + - name: sql-bench-07 + host: "sql-bench-07.soln.local" + - name: sql-bench-08 + host: "sql-bench-08.soln.local" + +hammerdb: + tprocc: + warehouses: 10000 + build_virtual_users: 32 + load_virtual_users: 200 + driver: timed + rampup: 5 + duration: 15 + total_iterations: 10000000 + all_warehouses: true + checkpoint: true + time_profile: false + +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +kubernetes: + namespace: hammerdb + job_ttl: 86400 + +storage_metrics: + enabled: true + pure: + host: "10.21.158.130" + api_token: "b2ef5116-a2e9-6b79-6987-aa7407038f10" + volume: "" + poll_interval: 5 + verify_ssl: false diff --git a/examples/scale-tests/ora-rac-hdb-scale.yaml b/examples/scale-tests/ora-rac-hdb-scale.yaml new file mode 100644 index 0000000..e135188 --- /dev/null +++ b/examples/scale-tests/ora-rac-hdb-scale.yaml @@ -0,0 +1,108 @@ +# ============================================================================ +# HammerDB-Scale Configuration +# ============================================================================ +# Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md +# Guide: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md +# +# Quick start: +# 1. Review and edit the values below (especially targets, warehouses, and virtual users) +# 2. hammerdb-scale validate # check config, tools, and database connectivity +# 3. hammerdb-scale run --build --wait # build schemas and run the benchmark +# 4. hammerdb-scale results # aggregate results +# 5. hammerdb-scale report --open # generate HTML scorecard +# ============================================================================ + +name: ora-rac-hdb-scale +description: "ORACLE TPROCC benchmark — 2 targets" +default_benchmark: tprocc + +# ============================================================================ +# DATABASE TARGETS +# ============================================================================ +# "defaults" are inherited by all hosts — override any field per-host if needed. +# Add or remove hosts to match your environment. +targets: + defaults: + type: oracle + username: system + password: "Osmium76" + image: + repository: sillidata/hammerdb-scale-oracle + tag: latest + pull_policy: Always # Always | IfNotPresent | Never + oracle: + service: "tpcc_svc.puretec.purestorage.com" + port: 1521 + tablespace: "TPCC" # bigfile tablespace for benchmark data + temp_tablespace: "TEMP" # temp tablespace (usually default) + tprocc: + user: "TPCC" # TPC-C schema owner (created during build) + password: "Osmium76" + tproch: + user: "tpch" # TPC-H schema owner (created during build) + password: "Osmium76" + degree_of_parallel: 8 # Oracle parallel query degree + + hosts: + - name: slc6-c480-n2-a21-05 + host: "slc6-c480-n2-a21-05" + - name: slc6-c480-n2-a21-09 + host: "slc6-c480-n2-a21-09" + +# ============================================================================ +# BENCHMARK PARAMETERS +# ============================================================================ +# Tune these to control data size, concurrency, and test duration. +# Uncomment the other benchmark section to switch between TPC-C and TPC-H. +hammerdb: + tprocc: + warehouses: 100 # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target + build_virtual_users: 4 # parallel threads for schema creation + load_virtual_users: 4 # concurrent users during benchmark (tune to match CPU cores) + driver: timed # "timed" = run for fixed duration, "test" = single iteration + rampup: 5 # warm-up period before measuring (minutes) + duration: 10 # measurement window (minutes), 5-60 typical + total_iterations: 10000000 # max iterations (effectively unlimited with timed driver) + all_warehouses: true # distribute load across all warehouses + checkpoint: true # issue checkpoint before benchmark + time_profile: false # detailed per-transaction timing (adds overhead) +# tproch: +# scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB +# build_threads: 4 # parallel threads for data generation +# build_virtual_users: 1 # HammerDB orchestrator (keep at 1) +# load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run +# total_querysets: 1 # number of full 22-query runs + +# ============================================================================ +# KUBERNETES RESOURCES +# ============================================================================ +# Resource requests/limits for each HammerDB Job pod. +# Each target gets one pod — scale these based on virtual user count. +resources: + requests: + memory: "4Gi" + cpu: "4" + limits: + memory: "8Gi" + cpu: "8" + +# ============================================================================ +# KUBERNETES SETTINGS +# ============================================================================ +kubernetes: + namespace: hammerdb # namespace for benchmark jobs (must exist) + job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h) + +# ============================================================================ +# PURE STORAGE METRICS (optional) +# Uncomment to collect IOPS, latency, and bandwidth from a FlashArray +# during benchmarks. Metrics appear in the HTML scorecard. +# ============================================================================ +storage_metrics: + enabled: false + # pure: + # host: "" # FlashArray management IP or hostname + # api_token: "" # REST API token (Settings > Users > API Tokens) + # volume: "" # leave empty for array-level metrics + # poll_interval: 5 # collection interval in seconds + # verify_ssl: false diff --git a/examples/scale-tests/run-mssql-scale-tests.sh b/examples/scale-tests/run-mssql-scale-tests.sh new file mode 100755 index 0000000..3888550 --- /dev/null +++ b/examples/scale-tests/run-mssql-scale-tests.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# MSSQL 1,2,4,8 Scale Tests with per-scale reports +# Usage: ./run-mssql-scale-tests.sh + +set -e + +for scale in 1 2 4 6 8; do + echo "" + echo "============================================================" + echo " MSSQL SCALE TEST ${scale} — $(date)" + echo "============================================================" + + CONFIG="mssql-scale-${scale}.yaml" + + echo ">>> Running benchmark..." + hammerdb-scale run -f "$CONFIG" --wait --timeout 3600 + + echo ">>> Collecting results..." + hammerdb-scale results -f "$CONFIG" + + echo ">>> Generating report..." + hammerdb-scale report -f "$CONFIG" -o "mssql-report-scale-${scale}.html" + + echo ">>> Scale ${scale} complete." + echo "" +done + +echo "============================================================" +echo " ALL MSSQL SCALE TESTS COMPLETE — $(date)" +echo "============================================================" +echo "" +echo "Reports:" +ls -la mssql-report-scale-*.html diff --git a/examples/scale-tests/run-scale-tests.sh b/examples/scale-tests/run-scale-tests.sh new file mode 100755 index 0000000..a343c0d --- /dev/null +++ b/examples/scale-tests/run-scale-tests.sh @@ -0,0 +1,33 @@ +#!/bin/bash +# Oracle 1-8 Scale Tests with per-scale reports +# Usage: ./run-scale-tests.sh + +set -e + +for scale in 1 2 3 4 5 6 7 8; do + echo "" + echo "============================================================" + echo " ORACLE SCALE TEST ${scale} of 8 — $(date)" + echo "============================================================" + + CONFIG="oracle-scale-${scale}.yaml" + + echo ">>> Running benchmark..." + hammerdb-scale run -f "$CONFIG" --wait --timeout 3600 + + echo ">>> Collecting results..." + hammerdb-scale results -f "$CONFIG" + + echo ">>> Generating report..." + hammerdb-scale report -f "$CONFIG" -o "oracle-report-scale-${scale}.html" + + echo ">>> Scale ${scale} complete." + echo "" +done + +echo "============================================================" +echo " ALL 8 SCALE TESTS COMPLETE — $(date)" +echo "============================================================" +echo "" +echo "Reports:" +ls -la oracle-report-scale-*.html diff --git a/hack/build-images.sh b/hack/build-images.sh new file mode 100755 index 0000000..cacca12 --- /dev/null +++ b/hack/build-images.sh @@ -0,0 +1,202 @@ +#!/usr/bin/env bash +# Build and verify the hammerdb-scale container images. +# +# Builds the base image, then the Oracle extension FROM that base, then runs +# verification against both. Nothing is pushed and no moving tag is applied +# until every check passes. +# +# The ordering is not incidental: Dockerfile.oracle defaults to +# BASE_IMAGE=sillidata/hammerdb-scale:latest, so a careless build produces an +# "Oracle 6.0" image containing whatever the published base happens to be. +# This script always wires BASE_IMAGE explicitly and then verifies the result. +# +# USAGE +# hack/build-images.sh # local build, localhost/ +# hack/build-images.sh -r docker.io/sillidata # tag for a registry +# hack/build-images.sh -r docker.io/sillidata --push +# +# OPTIONS +# -r, --registry REG registry/namespace prefix (default: localhost) +# -v, --version VER HammerDB version to build (default: from Dockerfile) +# -t, --tag TAG primary immutable tag (default: the HammerDB version) +# --latest also apply the :latest tag (implied by --push) +# --push push all tags after verification +# --skip-verify build only; not recommended +# --base-only skip the Oracle image +# +# Requires podman or docker. Oracle Instant Client is x86_64 only, so both +# images are x86_64. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +cd "$REPO_ROOT" + +REGISTRY="localhost" +HDB_VERSION="" +PRIMARY_TAG="" +APPLY_LATEST=0 +DO_PUSH=0 +DO_VERIFY=1 +BASE_ONLY=0 + +while [[ $# -gt 0 ]]; do + case "$1" in + -r|--registry) REGISTRY="${2%/}"; shift 2 ;; + -v|--version) HDB_VERSION="$2"; shift 2 ;; + -t|--tag) PRIMARY_TAG="$2"; shift 2 ;; + --latest) APPLY_LATEST=1; shift ;; + --push) DO_PUSH=1; APPLY_LATEST=1; shift ;; + --skip-verify) DO_VERIFY=0; shift ;; + --base-only) BASE_ONLY=1; shift ;; + -h|--help) sed -n '2,30p' "$0"; exit 0 ;; + *) echo "Unknown option: $1" >&2; exit 1 ;; + esac +done + +# --- Runtime --------------------------------------------------------------- +if command -v podman >/dev/null 2>&1; then + RUNTIME=podman +elif command -v docker >/dev/null 2>&1; then + RUNTIME=docker +else + echo "ERROR: neither podman nor docker found" >&2 + exit 1 +fi + +log() { echo -e "\033[1m[build]\033[0m $*"; } +fail() { echo -e "\033[31m[FAIL]\033[0m $*" >&2; exit 1; } +ok() { echo -e "\033[32m ok\033[0m $*"; } + +# --- Version resolution ---------------------------------------------------- +# The Dockerfile is the source of truth so the script cannot drift from it. +if [ -z "$HDB_VERSION" ]; then + HDB_VERSION="$(grep -oP '^ARG HAMMERDB_VERSION=\K\S+' Dockerfile)" +fi +[ -n "$HDB_VERSION" ] || fail "could not determine HAMMERDB_VERSION from Dockerfile" + +PRIMARY_TAG="${PRIMARY_TAG:-$HDB_VERSION}" +MAJOR_TAG="${HDB_VERSION%%.*}" + +CLI_VERSION="$(grep -oP '^version = "\K[^"]+' pyproject.toml || echo unknown)" +VCS_REF="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)" +BUILD_DATE="$(date -u +%Y-%m-%dT%H:%M:%SZ)" + +if ! git diff --quiet HEAD 2>/dev/null; then + VCS_REF="${VCS_REF}-dirty" +fi + +BASE_IMAGE="${REGISTRY}/hammerdb-scale" +ORACLE_IMAGE="${REGISTRY}/hammerdb-scale-oracle" + +log "runtime=${RUNTIME} registry=${REGISTRY}" +log "HammerDB=${HDB_VERSION} CLI=${CLI_VERSION} commit=${VCS_REF}" +log "tags: ${PRIMARY_TAG}, ${MAJOR_TAG}, ${CLI_VERSION}-hdb${HDB_VERSION}$([ $APPLY_LATEST -eq 1 ] && echo ', latest')" + +COMMON_ARGS=( + --build-arg "VCS_REF=${VCS_REF}" + --build-arg "BUILD_DATE=${BUILD_DATE}" + --build-arg "IMAGE_VERSION=${CLI_VERSION}" +) + +# --- Build: base ----------------------------------------------------------- +log "building ${BASE_IMAGE}:${PRIMARY_TAG}" +$RUNTIME build \ + -f Dockerfile \ + --build-arg "HAMMERDB_VERSION=${HDB_VERSION}" \ + "${COMMON_ARGS[@]}" \ + -t "${BASE_IMAGE}:${PRIMARY_TAG}" \ + . + +# --- Build: Oracle --------------------------------------------------------- +if [ "$BASE_ONLY" -eq 0 ]; then + log "building ${ORACLE_IMAGE}:${PRIMARY_TAG} FROM ${BASE_IMAGE}:${PRIMARY_TAG}" + $RUNTIME build \ + -f Dockerfile.oracle \ + --build-arg "BASE_IMAGE=${BASE_IMAGE}:${PRIMARY_TAG}" \ + "${COMMON_ARGS[@]}" \ + -t "${ORACLE_IMAGE}:${PRIMARY_TAG}" \ + . +fi + +# --- Verify ---------------------------------------------------------------- +verify_image() { + local image="$1" want_oracle="$2" + log "verifying ${image}" + + local home + home="$($RUNTIME run --rm --entrypoint /bin/bash "$image" -c 'echo -n "$HAMMERDB_HOME"')" + [ "$home" = "/opt/HammerDB-${HDB_VERSION}" ] \ + || fail "${image}: HAMMERDB_HOME is '${home}', expected /opt/HammerDB-${HDB_VERSION}" + ok "HAMMERDB_HOME=${home}" + + # The single check that catches an Oracle image built on a stale base. + local reported + reported="$($RUNTIME run --rm --entrypoint /bin/bash "$image" \ + -c 'cd $HAMMERDB_HOME && printf "" | ./hammerdbcli 2>&1 | head -1')" + grep -q "v${HDB_VERSION}" <<<"$reported" \ + || fail "${image}: hammerdbcli reports '${reported}', expected v${HDB_VERSION}" + ok "hammerdbcli: ${reported}" + + # PostgreSQL fails at runtime without the system libpq, which is easy to + # miss because HammerDB bundles Pgtcl itself. + $RUNTIME run --rm --entrypoint /bin/bash "$image" \ + -c 'ldconfig -p | grep -q libpq.so.5' \ + || fail "${image}: libpq.so.5 missing; PostgreSQL will fail to load Pgtcl" + ok "libpq present (PostgreSQL driver can load)" + + $RUNTIME run --rm --entrypoint /bin/bash "$image" \ + -c 'command -v /usr/local/bin/entrypoint.sh >/dev/null' \ + || fail "${image}: entrypoint.sh not at /usr/local/bin" + ok "entrypoint in place" + + if [ "$want_oracle" -eq 1 ]; then + $RUNTIME run --rm --entrypoint /bin/bash "$image" -c 'sqlplus -v >/dev/null' \ + || fail "${image}: sqlplus not working" + ok "Oracle Instant Client working" + fi +} + +if [ "$DO_VERIFY" -eq 1 ]; then + verify_image "${BASE_IMAGE}:${PRIMARY_TAG}" 0 + [ "$BASE_ONLY" -eq 0 ] && verify_image "${ORACLE_IMAGE}:${PRIMARY_TAG}" 1 +else + log "skipping verification (--skip-verify)" +fi + +# --- Tag ------------------------------------------------------------------- +# Only after verification, so a broken build never claims :latest. +apply_tags() { + local image="$1" + $RUNTIME tag "${image}:${PRIMARY_TAG}" "${image}:${MAJOR_TAG}" + $RUNTIME tag "${image}:${PRIMARY_TAG}" "${image}:${CLI_VERSION}-hdb${HDB_VERSION}" + [ "$APPLY_LATEST" -eq 1 ] && $RUNTIME tag "${image}:${PRIMARY_TAG}" "${image}:latest" + return 0 +} + +log "applying tags" +apply_tags "${BASE_IMAGE}" +[ "$BASE_ONLY" -eq 0 ] && apply_tags "${ORACLE_IMAGE}" + +# --- Push ------------------------------------------------------------------ +if [ "$DO_PUSH" -eq 1 ]; then + [ "$REGISTRY" = "localhost" ] && fail "refusing to push to 'localhost'; pass --registry" + push_tags() { + local image="$1" + for tag in "${PRIMARY_TAG}" "${MAJOR_TAG}" "${CLI_VERSION}-hdb${HDB_VERSION}" latest; do + log "pushing ${image}:${tag}" + $RUNTIME push "${image}:${tag}" + done + } + push_tags "${BASE_IMAGE}" + [ "$BASE_ONLY" -eq 0 ] && push_tags "${ORACLE_IMAGE}" + log "push complete" +else + echo + log "built and verified. Not pushed." + echo " To push: $0 --registry ${REGISTRY} --push" +fi + +echo +$RUNTIME images --format " {{.Repository}}:{{.Tag}} {{.Size}}" \ + | grep -E "hammerdb-scale(-oracle)?:" | sort -u diff --git a/pyproject.toml b/pyproject.toml index 55753b9..c42e324 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,8 +4,8 @@ build-backend = "hatchling.build" [project] name = "hammerdb-scale" -version = "2.0.3" -description = "CLI for orchestrating parallel HammerDB database benchmarks at scale on Kubernetes" +version = "2.0.4" +description = "CLI for orchestrating parallel HammerDB database benchmarks at scale" readme = {file = "README.md", content-type = "text/markdown"} license = "Apache-2.0" requires-python = ">=3.10" @@ -42,7 +42,7 @@ dependencies = [ dev = [ "pytest>=8.0.0", "pytest-cov>=4.1.0", - "ruff>=0.15.0", + "ruff>=0.15.0,<0.16.0", "mypy>=1.8.0", ] diff --git a/scripts b/scripts new file mode 120000 index 0000000..370d71a --- /dev/null +++ b/scripts @@ -0,0 +1 @@ +src/hammerdb_scale/chart/scripts \ No newline at end of file diff --git a/scripts/collect_pure_metrics.py b/scripts/collect_pure_metrics.py deleted file mode 100644 index 677080f..0000000 --- a/scripts/collect_pure_metrics.py +++ /dev/null @@ -1,521 +0,0 @@ -#!/usr/bin/env python3 -""" -Pure Storage FlashArray Metrics Collector -Collects array-level performance metrics using REST API 2.x - -Usage: - python3 collect_pure_metrics.py --host 10.21.158.110 --token YOUR_TOKEN --duration 60 -""" - -import argparse -import json -import logging -import os -import signal -import sys -import time -import urllib3 -from datetime import datetime, timezone -from typing import Dict, List, Optional - -try: - import requests -except ImportError: - print("ERROR: 'requests' module not found. Install with: pip install requests") - sys.exit(1) - -# Disable SSL warnings if needed -urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) - -# Setup logging -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - datefmt='%Y-%m-%d %H:%M:%S' -) -logger = logging.getLogger(__name__) - - -class PureStorageCollector: - """Simple Pure Storage FlashArray performance metrics collector.""" - - def __init__( - self, - array_host: str, - api_token: str, - poll_interval: int = 5, - verify_ssl: bool = False, - api_version: str = "2.4" - ): - """ - Initialize the collector. - - Args: - array_host: FlashArray management IP or hostname - api_token: API token for authentication - poll_interval: Seconds between polls (default: 5) - verify_ssl: Verify SSL certificates (default: False) - api_version: REST API version (default: 2.4) - """ - self.array_host = array_host.strip() - self.api_token = api_token.strip() - self.poll_interval = poll_interval - self.verify_ssl = verify_ssl - self.api_version = api_version - - # Build base URL - self.base_url = f"https://{self.array_host}/api/{self.api_version}" - - # Storage for metrics - self.metrics_data: List[Dict] = [] - self.running = False - self.session_token = None - - logger.info(f"Initialized Pure Storage collector for {self.array_host}") - logger.info(f"API Version: {self.api_version}") - logger.info(f"Poll Interval: {self.poll_interval}s") - - if not self.verify_ssl: - logger.warning("SSL certificate verification is DISABLED - not recommended for production") - - def login(self) -> bool: - """ - Login to Pure Storage array and get session token. - - Returns: - True if successful, False otherwise - """ - logger.info("Logging in to Pure Storage array...") - - try: - response = requests.post( - f"{self.base_url}/login", - headers={"api-token": self.api_token, "Content-Type": "application/json"}, - verify=self.verify_ssl, - timeout=10 - ) - - if response.status_code == 200: - self.session_token = response.headers.get('x-auth-token') - if self.session_token: - logger.info("Login successful") - return True - else: - logger.error("No x-auth-token in login response") - return False - else: - logger.error(f"Login failed - HTTP {response.status_code}: {response.text}") - return False - - except requests.exceptions.RequestException as e: - logger.error(f"Login request error: {e}") - return False - - def _make_request(self, endpoint: str, method: str = "GET") -> Optional[Dict]: - """ - Make HTTP request to Pure Storage API. - - Args: - endpoint: API endpoint (e.g., "/arrays/performance") - method: HTTP method (default: GET) - - Returns: - JSON response as dict, or None on error - """ - if not self.session_token: - logger.error("No session token. Call login() first.") - return None - - url = f"{self.base_url}{endpoint}" - headers = { - "x-auth-token": self.session_token, - "Content-Type": "application/json" - } - - try: - response = requests.request( - method=method, - url=url, - headers=headers, - verify=self.verify_ssl, - timeout=10 - ) - response.raise_for_status() - return response.json() - - except requests.exceptions.HTTPError as e: - logger.error(f"HTTP error for {endpoint}: {e}") - if hasattr(e.response, 'text'): - logger.error(f"Response: {e.response.text}") - return None - except requests.exceptions.RequestException as e: - logger.error(f"Request error for {endpoint}: {e}") - return None - except json.JSONDecodeError as e: - logger.error(f"JSON decode error for {endpoint}: {e}") - return None - except Exception as e: - logger.error(f"Unexpected error for {endpoint}: {e}") - return None - - def test_connection(self) -> bool: - """ - Test connection and authentication to the array. - - Returns: - True if successful, False otherwise - """ - logger.info("Testing connection to Pure Storage array...") - - # First login - if not self.login(): - logger.error("Failed to login to Pure Storage array") - return False - - # Then test with a simple API call - result = self._make_request("/arrays") - - if result and 'items' in result and len(result['items']) > 0: - array_info = result['items'][0] - logger.info(f"Successfully connected to array: {array_info.get('name', 'Unknown')}") - logger.info(f"Array ID: {array_info.get('id', 'Unknown')}") - return True - else: - logger.error("Failed to get array information") - return False - - def collect_array_performance(self) -> Optional[Dict]: - """ - Collect array-level performance metrics. - - Returns: - Dict with performance metrics or None if failed - """ - result = self._make_request("/arrays/performance") - - if not result or 'items' not in result or len(result['items']) == 0: - logger.error("No performance data returned from array") - return None - - perf = result['items'][0] - - # Extract and calculate metrics - read_iops = perf.get('reads_per_sec', 0) - write_iops = perf.get('writes_per_sec', 0) - read_bw_bytes = perf.get('read_bytes_per_sec', 0) - write_bw_bytes = perf.get('write_bytes_per_sec', 0) - - # Calculate average block sizes (avoid division by zero) - avg_read_block_kb = (read_bw_bytes / read_iops / 1024) if read_iops > 0 else 0 - avg_write_block_kb = (write_bw_bytes / write_iops / 1024) if write_iops > 0 else 0 - - metrics = { - 'timestamp': datetime.now(timezone.utc).isoformat(), - 'read_latency_us': perf.get('usec_per_read_op', 0), - 'write_latency_us': perf.get('usec_per_write_op', 0), - 'read_iops': read_iops, - 'write_iops': write_iops, - 'read_bandwidth_mbps': read_bw_bytes / (1024 * 1024), - 'write_bandwidth_mbps': write_bw_bytes / (1024 * 1024), - 'avg_read_block_size_kb': avg_read_block_kb, - 'avg_write_block_size_kb': avg_write_block_kb, - 'queue_depth': perf.get('queue_depth', 0), - } - - return metrics - - def start_collection(self, duration: int): - """ - Start collecting metrics for specified duration. - - Args: - duration: Collection duration in seconds - """ - logger.info(f"Starting metrics collection for {duration} seconds...") - - if not self.test_connection(): - logger.error("Connection test failed. Exiting.") - sys.exit(1) - - self.running = True - start_time = time.time() - end_time = start_time + duration - collection_count = 0 - - # Handle SIGTERM for graceful early stop (sent by entrypoint when benchmark finishes) - def _handle_sigterm(signum, frame): - logger.info("Received SIGTERM — finishing collection") - self.running = False - - signal.signal(signal.SIGTERM, _handle_sigterm) - - try: - while self.running and time.time() < end_time: - metrics = self.collect_array_performance() - - if metrics: - self.metrics_data.append(metrics) - collection_count += 1 - logger.info( - f"Sample #{collection_count}: " - f"R:{metrics['read_iops']:.0f} IOPS " - f"W:{metrics['write_iops']:.0f} IOPS " - f"R_lat:{metrics['read_latency_us']:.0f}µs " - f"W_lat:{metrics['write_latency_us']:.0f}µs" - ) - else: - logger.warning(f"Failed to collect metrics (attempt {collection_count + 1})") - - # Sleep until next collection - remaining = end_time - time.time() - if remaining <= 0: - break - sleep_time = min(self.poll_interval, remaining) - time.sleep(sleep_time) - - except KeyboardInterrupt: - logger.info("Collection interrupted by user") - finally: - self.running = False - actual_duration = time.time() - start_time - logger.info( - f"Collection complete. Gathered {collection_count} samples " - f"over {actual_duration:.1f} seconds" - ) - - def get_summary_statistics(self) -> Dict: - """ - Calculate summary statistics from collected metrics. - - Returns: - Dict with min, max, average, and percentile statistics - """ - if not self.metrics_data: - return {} - - def calc_stats(values: List[float]) -> Dict: - """Calculate statistics for a list of values.""" - if not values: - return {'min': 0, 'max': 0, 'avg': 0, 'p95': 0, 'p99': 0} - - sorted_vals = sorted(values) - count = len(sorted_vals) - - # Calculate percentile indices - p95_idx = int(count * 0.95) - p99_idx = int(count * 0.99) - - return { - 'min': sorted_vals[0], - 'max': sorted_vals[-1], - 'avg': sum(sorted_vals) / count, - 'p95': sorted_vals[p95_idx] if p95_idx < count else sorted_vals[-1], - 'p99': sorted_vals[p99_idx] if p99_idx < count else sorted_vals[-1], - } - - # Extract metric lists - read_lat = [m['read_latency_us'] for m in self.metrics_data] - write_lat = [m['write_latency_us'] for m in self.metrics_data] - read_iops = [m['read_iops'] for m in self.metrics_data] - write_iops = [m['write_iops'] for m in self.metrics_data] - read_bw = [m['read_bandwidth_mbps'] for m in self.metrics_data] - write_bw = [m['write_bandwidth_mbps'] for m in self.metrics_data] - read_block = [m['avg_read_block_size_kb'] for m in self.metrics_data] - write_block = [m['avg_write_block_size_kb'] for m in self.metrics_data] - - # Calculate statistics - read_lat_stats = calc_stats(read_lat) - write_lat_stats = calc_stats(write_lat) - read_iops_stats = calc_stats(read_iops) - write_iops_stats = calc_stats(write_iops) - read_bw_stats = calc_stats(read_bw) - write_bw_stats = calc_stats(write_bw) - read_block_stats = calc_stats(read_block) - write_block_stats = calc_stats(write_block) - - return { - 'source': 'array', - 'source_name': self.array_host, - 'sample_count': len(self.metrics_data), - 'read_latency_us_min': read_lat_stats['min'], - 'read_latency_us_max': read_lat_stats['max'], - 'read_latency_us_avg': read_lat_stats['avg'], - 'read_latency_us_p95': read_lat_stats['p95'], - 'read_latency_us_p99': read_lat_stats['p99'], - 'write_latency_us_min': write_lat_stats['min'], - 'write_latency_us_max': write_lat_stats['max'], - 'write_latency_us_avg': write_lat_stats['avg'], - 'write_latency_us_p95': write_lat_stats['p95'], - 'write_latency_us_p99': write_lat_stats['p99'], - 'read_iops_min': read_iops_stats['min'], - 'read_iops_max': read_iops_stats['max'], - 'read_iops_avg': read_iops_stats['avg'], - 'write_iops_min': write_iops_stats['min'], - 'write_iops_max': write_iops_stats['max'], - 'write_iops_avg': write_iops_stats['avg'], - 'read_bandwidth_mbps_min': read_bw_stats['min'], - 'read_bandwidth_mbps_max': read_bw_stats['max'], - 'read_bandwidth_mbps_avg': read_bw_stats['avg'], - 'write_bandwidth_mbps_min': write_bw_stats['min'], - 'write_bandwidth_mbps_max': write_bw_stats['max'], - 'write_bandwidth_mbps_avg': write_bw_stats['avg'], - 'avg_read_block_size_kb_min': read_block_stats['min'], - 'avg_read_block_size_kb_max': read_block_stats['max'], - 'avg_read_block_size_kb_avg': read_block_stats['avg'], - 'avg_write_block_size_kb_min': write_block_stats['min'], - 'avg_write_block_size_kb_max': write_block_stats['max'], - 'avg_write_block_size_kb_avg': write_block_stats['avg'], - } - - def save_results(self, output_file: str): - """ - Save collected metrics and summary to JSON file. - - Args: - output_file: Path to output JSON file - """ - results = { - 'metadata': { - 'array_host': self.array_host, - 'poll_interval_sec': self.poll_interval, - 'api_version': self.api_version, - }, - 'summary': self.get_summary_statistics(), - 'raw_metrics': self.metrics_data, - } - - try: - output_dir = os.path.dirname(output_file) - if output_dir: - os.makedirs(output_dir, exist_ok=True) - - with open(output_file, 'w') as f: - json.dump(results, f, indent=2) - logger.info(f"Results saved to {output_file}") - except Exception as e: - logger.error(f"Failed to save results: {e}") - - -def main(): - """Main entry point.""" - parser = argparse.ArgumentParser( - description='Collect Pure Storage FlashArray performance metrics', - formatter_class=argparse.RawDescriptionHelpFormatter, - epilog=''' -Examples: - # Collect array-level metrics for 60 seconds - %(prog)s --host 10.21.158.110 --token abc123 --duration 60 - - # Use environment variables for credentials - export PURE_HOST=10.21.158.110 - export PURE_API_TOKEN=abc123 - %(prog)s --duration 60 --output /tmp/metrics.json - ''' - ) - - parser.add_argument( - '--host', - default=os.getenv('PURE_HOST'), - help='FlashArray management IP or hostname (or set PURE_HOST env var)' - ) - parser.add_argument( - '--token', - default=os.getenv('PURE_API_TOKEN'), - help='API token for authentication (or set PURE_API_TOKEN env var)' - ) - parser.add_argument( - '--duration', - type=int, - default=int(os.getenv('PURE_DURATION', '60')), - help='Collection duration in seconds (default: 60)' - ) - parser.add_argument( - '--interval', - type=int, - default=int(os.getenv('PURE_INTERVAL', '5')), - help='Polling interval in seconds (default: 5)' - ) - parser.add_argument( - '--output', - default=os.getenv('PURE_OUTPUT', 'pure_metrics.json'), - help='Output JSON file path (default: pure_metrics.json)' - ) - parser.add_argument( - '--no-verify-ssl', - action='store_true', - default=os.getenv('PURE_NO_VERIFY_SSL', '').lower() in ('true', '1', 'yes'), - help='Disable SSL certificate verification' - ) - parser.add_argument( - '--api-version', - default=os.getenv('PURE_API_VERSION', '2.4'), - help='Pure Storage REST API version (default: 2.4)' - ) - parser.add_argument( - '--verbose', - action='store_true', - help='Enable verbose logging' - ) - - args = parser.parse_args() - - # Validate required arguments - if not args.host: - parser.error("--host is required (or set PURE_HOST environment variable)") - if not args.token: - parser.error("--token is required (or set PURE_API_TOKEN environment variable)") - - # Set log level - if args.verbose: - logger.setLevel(logging.DEBUG) - - # Create collector - collector = PureStorageCollector( - array_host=args.host, - api_token=args.token, - poll_interval=args.interval, - verify_ssl=not args.no_verify_ssl, - api_version=args.api_version - ) - - # Start collection - try: - collector.start_collection(args.duration) - collector.save_results(args.output) - - # Print summary - summary = collector.get_summary_statistics() - if summary: - print("\n" + "=" * 60) - print("PURE STORAGE PERFORMANCE SUMMARY") - print("=" * 60) - print(f"Source: {summary['source']} - {summary['source_name']}") - print(f"Samples: {summary['sample_count']}") - print(f"\nRead Latency (µs): avg={summary.get('read_latency_us_avg', 0):.0f} " - f"p95={summary.get('read_latency_us_p95', 0):.0f} " - f"p99={summary.get('read_latency_us_p99', 0):.0f}") - print(f"Write Latency (µs): avg={summary.get('write_latency_us_avg', 0):.0f} " - f"p95={summary.get('write_latency_us_p95', 0):.0f} " - f"p99={summary.get('write_latency_us_p99', 0):.0f}") - print(f"\nRead IOPS: avg={summary.get('read_iops_avg', 0):.0f} " - f"max={summary.get('read_iops_max', 0):.0f}") - print(f"Write IOPS: avg={summary.get('write_iops_avg', 0):.0f} " - f"max={summary.get('write_iops_max', 0):.0f}") - print(f"\nRead BW (MB/s): avg={summary.get('read_bandwidth_mbps_avg', 0):.1f} " - f"max={summary.get('read_bandwidth_mbps_max', 0):.1f}") - print(f"Write BW (MB/s): avg={summary.get('write_bandwidth_mbps_avg', 0):.1f} " - f"max={summary.get('write_bandwidth_mbps_max', 0):.1f}") - print(f"\nAvg Read Block (KB): {summary.get('avg_read_block_size_kb_avg', 0):.1f}") - print(f"Avg Write Block (KB): {summary.get('avg_write_block_size_kb_avg', 0):.1f}") - print("=" * 60) - else: - logger.warning("No metrics collected") - sys.exit(1) - - except Exception as e: - logger.error(f"Collection failed: {e}") - sys.exit(1) - - -if __name__ == '__main__': - main() diff --git a/scripts/mssql/build_schema_tprocc.tcl b/scripts/mssql/build_schema_tprocc.tcl deleted file mode 100644 index a7b8d1e..0000000 --- a/scripts/mssql/build_schema_tprocc.tcl +++ /dev/null @@ -1,98 +0,0 @@ -#!/bin/tclsh -# Load environment variables -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) - -# TPROC-C variables -set tprocc_database_name $::env(TPROCC_DATABASE_NAME) -set tprocc_build_virtual_users $::env(TPROCC_BUILD_VIRTUAL_USERS) -set warehouses $::env(WAREHOUSES) -set tprocc_driver_type $::env(TPROCC_DRIVER_TYPE) -set tprocc_allwarehouse $::env(TPROCC_ALLWAREHOUSE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD SQL_SERVER_HOST TPROCC_DATABASE_NAME WAREHOUSES TPROCC_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Initialize HammerDB -puts "SETTING UP TPROC-C SCHEMA BUILD" -dbset db mssqls - -# Set benchmark to TPC-C -dbset bm TPC-C - -# Configure connection -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-C Schema Build -diset tpcc mssqls_count_ware $warehouses -diset tpcc mssqls_num_vu $tprocc_build_virtual_users -diset tpcc mssqls_dbase $tprocc_database_name -diset tpcc mssqls_driver $tprocc_driver_type - -# Handle allwarehouse setting -if {$tprocc_allwarehouse eq "true"} { - diset tpcc mssqls_allwarehouse true -} else { - diset tpcc mssqls_allwarehouse false -} - -# Check if BCP option is enabled (now using common USE_BCP variable) -if {[info exists ::env(USE_BCP)] && $::env(USE_BCP) eq "true"} { - puts "Using BCP for data loading" - diset tpcc mssqls_use_bcp true -} else { - diset tpcc mssqls_use_bcp false - puts "Using standard data loading (BCP disabled)" -} - -# Load the TPC-C script -loadscript - -# Print current configuration -puts "Current TPROC-C configuration:" -print dict - -# Build the schema -puts "Starting TPROC-C schema build..." -buildschema - -puts "TPROC-C SCHEMA BUILD COMPLETE" \ No newline at end of file diff --git a/scripts/mssql/build_schema_tproch.tcl b/scripts/mssql/build_schema_tproch.tcl deleted file mode 100644 index 7dc176c..0000000 --- a/scripts/mssql/build_schema_tproch.tcl +++ /dev/null @@ -1,113 +0,0 @@ -#!/bin/tclsh -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) - -# TPROC-H variables -set tproch_database_name $::env(TPROCH_DATABASE_NAME) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_build_virtual_users $::env(TPROCH_BUILD_VIRTUAL_USERS) -set tproch_clustered_columnstore $::env(TPROCH_USE_CLUSTERED_COLUMNSTORE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD SQL_SERVER_HOST TPROCH_DATABASE_NAME TPROCH_SCALE_FACTOR TPROCH_DRIVER TPROCH_BUILD_THREADS TPROCH_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Initialize HammerDB -puts "SETTING UP TPROC-H SCHEMA BUILD" -puts "Target database: $tproch_database_name" -puts "Scale factor: $tproch_scale_factor" -puts "Build threads: $tproch_build_threads" -puts "Build virtual users: $tproch_build_virtual_users (for logging; build uses threads)" - -# Set database to SQL Server -dbset db $tproch_driver - -# Set benchmark to TPC-H -dbset bm TPC-H - -# Configure connection -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-H Schema Build -diset tpch mssqls_tpch_dbase $tproch_database_name -diset tpch mssqls_scale_fact $tproch_scale_factor -diset tpch mssqls_num_tpch_threads $tproch_build_threads - -# Configure columnstore if enabled -if {$tproch_clustered_columnstore eq "true"} { - puts "Enabling Clustered Columnstore Indexes" - diset tpch mssqls_colstore true -} else { - puts "Using standard row-based storage" - diset tpch mssqls_colstore false -} - -# Check if BCP option is enabled (now using common USE_BCP variable) -if {[info exists ::env(USE_BCP)] && $::env(USE_BCP) eq "true"} { - puts "Using BCP for data loading" - diset tpch mssqls_tpch_use_bcp true -} else { - diset tpch mssqls_tpch_use_bcp false - puts "Using standard data loading (BCP disabled)" -} - -# Set MAXDOP if environment variable exists -if {[info exists ::env(TPROCH_MAXDOP)]} { - diset tpch mssqls_maxdop $::env(TPROCH_MAXDOP) -} else { - # Use default value of 2 - diset tpch mssqls_maxdop 2 -} - -# Load the TPC-H script -loadscript - -# Print current configuration -puts "Current TPROC-H configuration:" -print dict - -# Build the schema -puts "Starting TPROC-H schema build..." -buildschema - -puts "TPROC-H SCHEMA BUILD COMPLETE" \ No newline at end of file diff --git a/scripts/mssql/generic_tprocc_result.tcl b/scripts/mssql/generic_tprocc_result.tcl deleted file mode 100644 index ae7f3ce..0000000 --- a/scripts/mssql/generic_tprocc_result.tcl +++ /dev/null @@ -1,48 +0,0 @@ -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to get the output from the output file -proc getoutput {filename} { - set fd [open $filename r] - set output [read $fd] - close $fd - return $output -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/mssqls_tprocc -set filename $::outputfile -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "Job ID not found in the output file." - exit 1 -} - -set output_filename [file normalize "${filename}_${jobid}.out"] - -# Open the file for writing -set fileId [open $output_filename "w"] - -# Write to the file -puts $fileId "TRANSACTION RESPONSE TIMES" -puts $fileId [job $jobid timing] - -puts $fileId "TRANSACTION COUNT" -puts $fileId [job $jobid tcount] - -puts $fileId "HAMMERDB RESULT" -puts $fileId [job $jobid result] - -# Close the file -close $fileId - -# Optionally, print the output to the console -set output [getoutput $output_filename] -puts $output diff --git a/scripts/mssql/load_test_tprocc.tcl b/scripts/mssql/load_test_tprocc.tcl deleted file mode 100644 index 92408b6..0000000 --- a/scripts/mssql/load_test_tprocc.tcl +++ /dev/null @@ -1,137 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for SQL Server connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) - -# TPROC-C specific variables -set virtual_users $::env(VIRTUAL_USERS) -set tprocc_database_name $::env(TPROCC_DATABASE_NAME) -set tprocc_driver $::env(TPROCC_DRIVER) -set rampup $::env(RAMPUP) -set duration $::env(DURATION) -set total_iterations $::env(TOTAL_ITERATIONS) -set tmpdir $::env(TMPDIR) -set warehouses $::env(WAREHOUSES) -set tprocc_log_to_temp $::env(TPROCC_LOG_TO_TEMP) -set tprocc_use_transaction_counter $::env(TPROCC_USE_TRANSACTION_COUNTER) -set tprocc_checkpoint $::env(TPROCC_CHECKPOINT) -set tprocc_timeprofile $::env(TPROCC_TIMEPROFILE) - -# Check if all required environment variables are set -if {![info exists username] || ![info exists password] || ![info exists sql_server_host]} { - puts "Error: Environment variables USERNAME, PASSWORD, and SQL_SERVER_HOST must be set." - exit 1 -} - -# Initialize HammerDB -puts "SETTING UP TPROC-C LOAD TEST" -puts "Environment variables loaded:" -puts " Database: $tprocc_database_name" -puts " Virtual Users: $virtual_users" -puts " Duration: $duration minutes" -puts " Rampup: $rampup minutes" -puts " Total Iterations: $total_iterations" - -# Set up the database connection details for MSSQL -dbset db $tprocc_driver - -# Set the benchmark to TPC-C -dbset bm TPC-C - -# Set up the database connection details for MSSQL -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-C benchmark parameters -diset tpcc mssqls_dbase $tprocc_database_name -diset tpcc mssqls_driver timed -diset tpcc mssqls_total_iterations $total_iterations -diset tpcc mssqls_rampup $rampup -diset tpcc mssqls_duration $duration -diset tpcc mssqls_allwarehouse true -diset tpcc mssqls_count_ware $warehouses - -# Disable keying and thinking time for maximum storage stress testing -# This runs transactions back-to-back without simulated human delays -diset tpcc mssqls_keyandthink false - -# Set checkpoint and timeprofile if they are true -if {$tprocc_checkpoint eq "true"} { - diset tpcc mssqls_checkpoint true -} -if {$tprocc_timeprofile eq "true"} { - diset tpcc mssqls_timeprofile true -} - -# Configure test options and load scripts -vuset logtotemp $tprocc_log_to_temp -loadscript - -puts "STARTING TPROC-C VIRTUAL USERS" -puts "Virtual Users: $virtual_users" -puts "Duration: $duration minutes" -puts "Output will be logged to: $tmpdir/mssqls_tprocc" - -vuset vu $virtual_users -vucreate -puts "TEST STARTED" - -# Handle transaction counter based on environment variable -if {$tprocc_use_transaction_counter eq "true"} { - puts "Starting transaction counter..." - tcstart - tcstatus -} - -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -vudestroy - -if {$tprocc_use_transaction_counter eq "true"} { - puts "Stopping transaction counter..." - tcstop -} - -puts "Virtual users destroyed" -puts "TPROC-C LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/mssqls_tprocc" -set of [ open $tmpdir/mssqls_tprocc w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/mssqls_tprocc" \ No newline at end of file diff --git a/scripts/mssql/load_test_tproch.tcl b/scripts/mssql/load_test_tproch.tcl deleted file mode 100644 index 272a995..0000000 --- a/scripts/mssql/load_test_tproch.tcl +++ /dev/null @@ -1,114 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for SQL Server connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set sql_server_host $::env(SQL_SERVER_HOST) -set tmpdir $::env(TMPDIR) - -# TPROC-H specific variables -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_database_name $::env(TPROCH_DATABASE_NAME) -set tproch_virtual_users $::env(TPROCH_VIRTUAL_USERS) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_use_clustered_columnstore $::env(TPROCH_USE_CLUSTERED_COLUMNSTORE) -set tproch_total_querysets $::env(TPROCH_TOTAL_QUERYSETS) -set tproch_log_to_temp $::env(TPROCH_LOG_TO_TEMP) - -# Validate required environment variables -foreach var {USERNAME PASSWORD SQL_SERVER_HOST TPROCH_DRIVER TPROCH_DATABASE_NAME} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty." - exit 1 - } -} - -# Initialize HammerDB -puts "SETTING UP TPROC-H LOAD TEST" -puts "Environment variables loaded:" -puts "Database: $tproch_database_name" -puts "Virtual Users: $tproch_virtual_users" - -# Set up the database connection details for MSSQL -dbset db $tproch_driver - -# Set the benchmark to TPC-H -dbset bm TPC-H - -# Set up the database connection details for MSSQL -diset connection mssqls_server $sql_server_host -diset connection mssqls_linux_server $sql_server_host -diset connection mssqls_uid $username -diset connection mssqls_pass $password -diset connection mssqls_tcp true -diset connection mssqls_authentication sql - -# SSL/TLS connection settings with proper boolean conversion -if {[info exists ::env(MSSQLS_ENCRYPT_CONNECTION)]} { - if {$::env(MSSQLS_ENCRYPT_CONNECTION) eq "true"} { - diset connection mssqls_encrypt_connection true - } else { - diset connection mssqls_encrypt_connection false - } -} else { - diset connection mssqls_encrypt_connection true -} - -if {[info exists ::env(MSSQLS_TRUST_SERVER_CERT)]} { - if {$::env(MSSQLS_TRUST_SERVER_CERT) eq "true"} { - diset connection mssqls_trust_server_cert true - } else { - diset connection mssqls_trust_server_cert false - } -} else { - diset connection mssqls_trust_server_cert true -} - -# ODBC driver setting -if {[info exists ::env(MSSQLS_ODBC_DRIVER)]} { - diset connection mssqls_linux_odbc $::env(MSSQLS_ODBC_DRIVER) -} else { - diset connection mssqls_linux_odbc "ODBC Driver 18 for SQL Server" -} - -# Configure TPC-H benchmark parameters -diset tpch mssqls_tpch_dbase $tproch_database_name -diset tpch mssqls_total_querysets $tproch_total_querysets -diset tpch mssqls_scale_fact $tproch_scale_factor -diset tpch mssqls_num_tpch_threads $tproch_build_threads -if {$tproch_use_clustered_columnstore eq "true"} { - diset tpch mssqls_colstore true -} else { - diset tpch mssqls_colstore false -} - -# Test run parameters -set vuser_count $tproch_virtual_users - -# Configure test options and load scripts -vuset logtotemp $tproch_log_to_temp -loadscript - -puts "STARTING TPROC-H VIRTUAL USERS" -puts "Virtual Users: $vuser_count" -puts "Output will be logged to: $tmpdir/mssqls_tproch" - -vuset vu $vuser_count -vucreate -puts "TEST STARTED" -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -puts "Waiting for test completion..." -vucomplete -puts "Test completion confirmed" -vudestroy -puts "Virtual users destroyed" -puts "TPROC-H LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/mssqls_tproch" -set of [ open $tmpdir/mssqls_tproch w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/mssqls_tproch" \ No newline at end of file diff --git a/scripts/mssql/parse_output_tproch.tcl b/scripts/mssql/parse_output_tproch.tcl deleted file mode 100644 index e8a91b3..0000000 --- a/scripts/mssql/parse_output_tproch.tcl +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/tclsh -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to extract total elapsed time from result data -proc extract_elapsed_time {result_data} { - # HammerDB result format: "Completed 1 query set(s) in 3812 seconds" - # This is the most reliable way to get timing data - - # Try to match "Completed X query set(s) in Y seconds" - if {[regexp {Completed\s+\d+\s+query set\(s\)\s+in\s+([0-9]+)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "elapsed X seconds" or "X seconds elapsed" - if {[regexp {elapsed\s+([0-9]+\.?[0-9]*)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "Total elapsed: X" or similar - if {[regexp {Total elapsed:\s+([0-9]+\.?[0-9]*)} $result_data match elapsed]} { - return $elapsed - } - - # If no pattern matched, return 0 - return 0 -} - -# Procedure to calculate QphH (Queries per Hour @ Scale Factor) -proc calculate_qphh {elapsed_seconds scale_factor} { - if {$elapsed_seconds <= 0} { - return 0 - } - - # TPC-H QphH = (22 queries / elapsed_seconds) * 3600 seconds/hour * scale_factor - # Standard formula: QphH@SF = (3600 / elapsed_time) * scale_factor - # For a single query set (22 queries), this represents queries per hour - set qphh [expr {(3600.0 / $elapsed_seconds) * $scale_factor}] - return [format "%.2f" $qphh] -} - -# Procedure to extract per-query timing from result data -proc extract_query_times {result_data} { - # HammerDB logs queries in format: "Vuser 1:query 14 completed in 61.676 seconds" - # Return a dict mapping query number to elapsed time - - array set query_times {} - - # Match pattern: "query X completed in Y seconds" - foreach line [split $result_data "\n"] { - if {[regexp {query\s+(\d+)\s+completed in\s+([0-9]+\.?[0-9]*)\s+seconds} $line match query_num elapsed]} { - set query_times($query_num) $elapsed - } - } - - return [array get query_times] -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/mssqls_tproch -set filename $::outputfile - -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "ERROR: Job ID not found in the output file." - exit 1 -} - -# Get scale factor from environment (default to 1 if not set) -if {[info exists ::env(TPROCH_SCALE_FACTOR)]} { - set scale_factor $::env(TPROCH_SCALE_FACTOR) -} else { - set scale_factor 1 -} - -set output_filename [file normalize "${filename}_${jobid}.out"] - -# IMPORTANT: HammerDB prints job results to stdout before our script runs -# The job object itself is empty, so we need to read from the log file where stdout was captured - -# Try to read the hammerdb log file which should contain the output -set log_file "/tmp/hammerdb.log" -set result_string "" - -if {[file exists $log_file]} { - set fd [open $log_file r] - set result_string [read $fd] - close $fd -} else { - puts "WARNING: Log file not found at $log_file" -} - -# Extract elapsed time from result data and calculate QphH -set elapsed_seconds [extract_elapsed_time $result_string] -set qphh [calculate_qphh $elapsed_seconds $scale_factor] - -# Extract per-query timing data -array set query_times_array [extract_query_times $result_string] - -# For reference - also get timing data (likely empty but shown for completeness) -set timing_data [job $jobid timing] - -# Open the file for writing -set fileId [open $output_filename "w"] - -# Write to the file -puts $fileId "TPC-H QUERY EXECUTION RESULTS" -puts $fileId "=============================" -puts $fileId "" -puts $fileId "PERFORMANCE METRICS" -puts $fileId "-------------------" -puts $fileId "Total Elapsed Time: $elapsed_seconds seconds" -puts $fileId "Scale Factor: $scale_factor" -puts $fileId "QphH@$scale_factor: $qphh" -puts $fileId "" -puts $fileId "PER-QUERY TIMING (SECONDS)" -puts $fileId "-------------------------" - -# Sort queries numerically and display -set query_nums [lsort -integer [array names query_times_array]] -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts $fileId [format "Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts $fileId "No per-query timing data found in logs" -} - -puts $fileId "" -puts $fileId "QUERY TIMING RESULTS (from job object)" -puts $fileId "---------------------------------------" -puts $fileId $timing_data -puts $fileId "" -puts $fileId "HAMMERDB RESULT SUMMARY" -puts $fileId "-----------------------" -puts $fileId $result_string - -# Close the file -close $fileId - -# Print the output to the console -set output [exec cat $output_filename] -puts $output - -# Also output the QphH metric in a parseable format for aggregation -puts "" -puts "=== TPC-H PERFORMANCE SUMMARY ===" -puts "Elapsed Time: $elapsed_seconds seconds" -puts "QphH@$scale_factor: $qphh" -puts "" -puts "Per-Query Timing:" -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts [format " Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts " No per-query data available" -} -puts "=================================" \ No newline at end of file diff --git a/scripts/oracle/build_schema_tprocc.tcl b/scripts/oracle/build_schema_tprocc.tcl deleted file mode 100644 index 5ebf9a7..0000000 --- a/scripts/oracle/build_schema_tprocc.tcl +++ /dev/null @@ -1,82 +0,0 @@ -#!/bin/tclsh -# Load environment variables -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) - -# TPROC-C variables -set tprocc_user $::env(TPROCC_USER) -set tprocc_password $::env(TPROCC_PASSWORD) -set tprocc_build_virtual_users $::env(TPROCC_BUILD_VIRTUAL_USERS) -set warehouses $::env(WAREHOUSES) -set tprocc_driver_type $::env(TPROCC_DRIVER_TYPE) -set tprocc_allwarehouse $::env(TPROCC_ALLWAREHOUSE) - -# Oracle-specific variables -set oracle_tablespace $::env(ORACLE_TABLESPACE) -set oracle_temp_tablespace $::env(ORACLE_TEMP_TABLESPACE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD HOST ORACLE_PORT ORACLE_SERVICE TPROCC_USER WAREHOUSES TPROCC_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-C SCHEMA BUILD FOR ORACLE" -puts "Target connection: $oracle_connection" -puts "TPCC schema user: $tprocc_user" -puts "Warehouses: $warehouses" -puts "Build virtual users: $tprocc_build_virtual_users" - -# Set database to Oracle -dbset db oracle - -# Set benchmark to TPC-C -dbset bm TPC-C - -# Configure connection -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-C Schema Build -diset tpcc tpcc_user $tprocc_user -diset tpcc tpcc_pass $tprocc_password -diset tpcc tpcc_def_tab $oracle_tablespace -diset tpcc tpcc_ol_tab $oracle_tablespace -diset tpcc tpcc_def_temp $oracle_temp_tablespace -diset tpcc count_ware $warehouses -diset tpcc num_vu $tprocc_build_virtual_users - -# Oracle-specific features (disabled by default for compatibility) -diset tpcc partition false -diset tpcc hash_clusters false -diset tpcc tpcc_tt_compat false - -# Handle allwarehouse setting -if {$tprocc_allwarehouse eq "true"} { - diset tpcc allwarehouse true -} else { - diset tpcc allwarehouse false -} - -# Load the TPC-C script -loadscript - -# Print current configuration -puts "Current TPROC-C configuration:" -print dict - -# Build the schema -puts "Starting TPROC-C schema build..." -buildschema - -puts "TPROC-C SCHEMA BUILD COMPLETE" diff --git a/scripts/oracle/build_schema_tproch.tcl b/scripts/oracle/build_schema_tproch.tcl deleted file mode 100644 index 9f4bcfc..0000000 --- a/scripts/oracle/build_schema_tproch.tcl +++ /dev/null @@ -1,72 +0,0 @@ -#!/bin/tclsh -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) - -# TPROC-H variables -set tproch_user $::env(TPROCH_USER) -set tproch_password $::env(TPROCH_PASSWORD) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_build_virtual_users $::env(TPROCH_BUILD_VIRTUAL_USERS) - -# Oracle-specific variables -set oracle_tablespace $::env(ORACLE_TABLESPACE) -set oracle_temp_tablespace $::env(ORACLE_TEMP_TABLESPACE) - -# Validate required environment variables -foreach var {USERNAME PASSWORD HOST ORACLE_PORT ORACLE_SERVICE TPROCH_USER TPROCH_SCALE_FACTOR TPROCH_DRIVER TPROCH_BUILD_THREADS TPROCH_BUILD_VIRTUAL_USERS} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty" - exit 1 - } -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-H SCHEMA BUILD FOR ORACLE" -puts "Target connection: $oracle_connection" -puts "TPCH schema user: $tproch_user" -puts "Scale factor: $tproch_scale_factor" -puts "Build threads: $tproch_build_threads" -puts "Build virtual users: $tproch_build_virtual_users (for logging; build uses threads)" - -# Set database to Oracle -dbset db $tproch_driver - -# Set benchmark to TPC-H -dbset bm TPC-H - -# Configure connection -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-H Schema Build -diset tpch tpch_user $tproch_user -diset tpch tpch_pass $tproch_password -diset tpch tpch_def_tab $oracle_tablespace -diset tpch tpch_def_temp $oracle_temp_tablespace -diset tpch scale_fact $tproch_scale_factor -diset tpch num_tpch_threads $tproch_build_threads - -# Oracle-specific features (disabled by default for compatibility) -diset tpch tpch_tt_compat false - -# Load the TPC-H script -loadscript - -# Print current configuration -puts "Current TPROC-H configuration:" -print dict - -# Build the schema -puts "Starting TPROC-H schema build..." -buildschema - -puts "TPROC-H SCHEMA BUILD COMPLETE" diff --git a/scripts/oracle/load_test_tprocc.tcl b/scripts/oracle/load_test_tprocc.tcl deleted file mode 100644 index 828702f..0000000 --- a/scripts/oracle/load_test_tprocc.tcl +++ /dev/null @@ -1,115 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for Oracle connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) - -# TPROC-C specific variables -set virtual_users $::env(VIRTUAL_USERS) -set tprocc_user $::env(TPROCC_USER) -set tprocc_password $::env(TPROCC_PASSWORD) -set tprocc_driver $::env(TPROCC_DRIVER) -set rampup $::env(RAMPUP) -set duration $::env(DURATION) -set total_iterations $::env(TOTAL_ITERATIONS) -set tmpdir $::env(TMPDIR) -set warehouses $::env(WAREHOUSES) -set tprocc_log_to_temp $::env(TPROCC_LOG_TO_TEMP) -set tprocc_use_transaction_counter $::env(TPROCC_USE_TRANSACTION_COUNTER) -set tprocc_checkpoint $::env(TPROCC_CHECKPOINT) -set tprocc_timeprofile $::env(TPROCC_TIMEPROFILE) - -# Check if all required environment variables are set -if {![info exists username] || ![info exists password] || ![info exists host] || ![info exists oracle_port] || ![info exists oracle_service]} { - puts "Error: Environment variables USERNAME, PASSWORD, HOST, ORACLE_PORT, and ORACLE_SERVICE must be set." - exit 1 -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-C LOAD TEST FOR ORACLE" -puts "Environment variables loaded:" -puts " Connection: $oracle_connection" -puts " TPCC User: $tprocc_user" -puts " Virtual Users: $virtual_users" -puts " Duration: $duration minutes" -puts " Rampup: $rampup minutes" -puts " Total Iterations: $total_iterations" - -# Set up the database connection details for Oracle -dbset db $tprocc_driver - -# Set the benchmark to TPC-C -dbset bm TPC-C - -# Set up the database connection details for Oracle -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-C benchmark parameters -diset tpcc tpcc_user $tprocc_user -diset tpcc tpcc_pass $tprocc_password -diset tpcc count_ware $warehouses - -# Oracle driver settings -diset tpcc ora_driver timed -diset tpcc total_iterations $total_iterations -diset tpcc rampup $rampup -diset tpcc duration $duration -diset tpcc allwarehouse true - -# Set checkpoint and timeprofile if they are true -if {$tprocc_checkpoint eq "true"} { - diset tpcc checkpoint true -} -if {$tprocc_timeprofile eq "true"} { - diset tpcc ora_timeprofile true -} - -# Disable keying and thinking time for consistent results -diset tpcc keyandthink false - -# Configure test options and load scripts -vuset logtotemp $tprocc_log_to_temp -loadscript - -puts "STARTING TPROC-C VIRTUAL USERS" -puts "Virtual Users: $virtual_users" -puts "Duration: $duration minutes" -puts "Output will be logged to: $tmpdir/oracle_tprocc" - -vuset vu $virtual_users -vucreate -puts "TEST STARTED" - -# Handle transaction counter based on environment variable -if {$tprocc_use_transaction_counter eq "true"} { - puts "Starting transaction counter..." - tcstart - tcstatus -} - -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -vudestroy - -if {$tprocc_use_transaction_counter eq "true"} { - puts "Stopping transaction counter..." - tcstop -} - -puts "Virtual users destroyed" -puts "TPROC-C LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/oracle_tprocc" -set of [ open $tmpdir/oracle_tprocc w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/oracle_tprocc" diff --git a/scripts/oracle/load_test_tproch.tcl b/scripts/oracle/load_test_tproch.tcl deleted file mode 100644 index 416c7ba..0000000 --- a/scripts/oracle/load_test_tproch.tcl +++ /dev/null @@ -1,93 +0,0 @@ -#!/bin/tclsh -# Fetch environment variables for Oracle connection -set username $::env(USERNAME) -set password $::env(PASSWORD) -set host $::env(HOST) -set oracle_port $::env(ORACLE_PORT) -set oracle_service $::env(ORACLE_SERVICE) -set tmpdir $::env(TMPDIR) - -# TPROC-H specific variables -set tproch_driver $::env(TPROCH_DRIVER) -set tproch_user $::env(TPROCH_USER) -set tproch_password $::env(TPROCH_PASSWORD) -set tproch_virtual_users $::env(TPROCH_VIRTUAL_USERS) -set tproch_scale_factor $::env(TPROCH_SCALE_FACTOR) -set tproch_build_threads $::env(TPROCH_BUILD_THREADS) -set tproch_total_querysets $::env(TPROCH_TOTAL_QUERYSETS) -set tproch_log_to_temp $::env(TPROCH_LOG_TO_TEMP) -set tproch_degree_of_parallel $::env(TPROCH_DEGREE_OF_PARALLEL) - -# Validate required environment variables -foreach var {USERNAME PASSWORD HOST ORACLE_PORT ORACLE_SERVICE TPROCH_DRIVER TPROCH_USER} { - if {![info exists ::env($var)] || $::env($var) eq ""} { - puts "Error: Environment variable $var is not set or empty." - exit 1 - } -} - -# Construct Oracle connection string -set oracle_connection "${host}:${oracle_port}/${oracle_service}" - -# Initialize HammerDB -puts "SETTING UP TPROC-H LOAD TEST FOR ORACLE" -puts "Environment variables loaded:" -puts "Connection: $oracle_connection" -puts "TPCH User: $tproch_user" -puts "Virtual Users: $tproch_virtual_users" -puts "Degree of Parallel: $tproch_degree_of_parallel" - -# Set up the database connection details for Oracle -dbset db $tproch_driver - -# Set the benchmark to TPC-H -dbset bm TPC-H - -# Set up the database connection details for Oracle -diset connection instance $oracle_connection -diset connection system_user $username -diset connection system_password $password - -# Configure TPC-H benchmark parameters -diset tpch tpch_user $tproch_user -diset tpch tpch_pass $tproch_password -diset tpch total_querysets $tproch_total_querysets -diset tpch scale_fact $tproch_scale_factor -diset tpch num_tpch_threads $tproch_build_threads - -# Oracle-specific settings -diset tpch degree_of_parallel $tproch_degree_of_parallel -diset tpch verbose false -diset tpch refresh_on false -diset tpch raise_query_error false - -# Test run parameters -set vuser_count $tproch_virtual_users - -# Configure test options and load scripts -vuset logtotemp $tproch_log_to_temp -loadscript - -puts "STARTING TPROC-H VIRTUAL USERS" -puts "Virtual Users: $vuser_count" -puts "Output will be logged to: $tmpdir/oracle_tproch" - -vuset vu $vuser_count -vucreate -puts "TEST STARTED" -puts "About to run vurun command..." -set jobid [ vurun ] -puts "vurun completed with job ID: $jobid" -puts "Waiting for test completion..." -vucomplete -puts "Test completion confirmed" -vudestroy -puts "Virtual users destroyed" -puts "TPROC-H LOAD TEST COMPLETE" - -# Write job ID to output file for parsing -puts "Creating output file at: $tmpdir/oracle_tproch" -set of [ open $tmpdir/oracle_tproch w ] -puts $of $jobid -close $of -puts "Job ID $jobid written to $tmpdir/oracle_tproch" diff --git a/scripts/oracle/parse_output_tproch.tcl b/scripts/oracle/parse_output_tproch.tcl deleted file mode 100644 index c48e1e3..0000000 --- a/scripts/oracle/parse_output_tproch.tcl +++ /dev/null @@ -1,168 +0,0 @@ -#!/bin/tclsh -# Procedure to get the job ID from the output file -proc getjobid {filename} { - set fd [open $filename r] - set jobid [lindex [split [gets $fd] =] 1] - close $fd - return $jobid -} - -# Procedure to extract total elapsed time from result data -proc extract_elapsed_time {result_data} { - # HammerDB result format: "Completed 1 query set(s) in 3812 seconds" - # This is the most reliable way to get timing data - - # Try to match "Completed X query set(s) in Y seconds" - if {[regexp {Completed\s+\d+\s+query set\(s\)\s+in\s+([0-9]+)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "elapsed X seconds" or "X seconds elapsed" - if {[regexp {elapsed\s+([0-9]+\.?[0-9]*)\s+seconds} $result_data match elapsed]} { - return $elapsed - } - - # Alternative: Try to match "Total elapsed: X" or similar - if {[regexp {Total elapsed:\s+([0-9]+\.?[0-9]*)} $result_data match elapsed]} { - return $elapsed - } - - # If no pattern matched, return 0 - return 0 -} - -# Procedure to calculate QphH (Queries per Hour @ Scale Factor) -proc calculate_qphh {elapsed_seconds scale_factor} { - if {$elapsed_seconds <= 0} { - return 0 - } - - # TPC-H QphH = (22 queries / elapsed_seconds) * 3600 seconds/hour * scale_factor - # Standard formula: QphH@SF = (3600 / elapsed_time) * scale_factor - # For a single query set (22 queries), this represents queries per hour - set qphh [expr {(3600.0 / $elapsed_seconds) * $scale_factor}] - return [format "%.2f" $qphh] -} - -# Procedure to extract per-query timing from result data -proc extract_query_times {result_data} { - # HammerDB logs queries in format: "Vuser 1:query 14 completed in 61.676 seconds" - # Return a dict mapping query number to elapsed time - - array set query_times {} - - # Match pattern: "query X completed in Y seconds" - foreach line [split $result_data "\n"] { - if {[regexp {query\s+(\d+)\s+completed in\s+([0-9]+\.?[0-9]*)\s+seconds} $line match query_num elapsed]} { - set query_times($query_num) $elapsed - } - } - - return [array get query_times] -} - -# Main script execution -set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/oracle_tproch -set filename $::outputfile - -set jobid [getjobid $filename] - -if {$jobid eq ""} { - puts "ERROR: Job ID not found in the output file." - exit 1 -} - -# Get scale factor from environment (default to 1 if not set) -if {[info exists ::env(TPROCH_SCALE_FACTOR)]} { - set scale_factor $::env(TPROCH_SCALE_FACTOR) -} else { - set scale_factor 1 -} - -set output_filename [file normalize "${filename}_${jobid}.out"] - -# IMPORTANT: HammerDB prints job results to stdout before our script runs -# The job object itself is empty, so we need to read from the log file where stdout was captured - -# Try to read the hammerdb log file which should contain the output -set log_file "/tmp/hammerdb.log" -set result_string "" - -if {[file exists $log_file]} { - set fd [open $log_file r] - set result_string [read $fd] - close $fd -} else { - puts "WARNING: Log file not found at $log_file" -} - -# Extract elapsed time from result data and calculate QphH -set elapsed_seconds [extract_elapsed_time $result_string] -set qphh [calculate_qphh $elapsed_seconds $scale_factor] - -# Extract per-query timing data -array set query_times_array [extract_query_times $result_string] - -# For reference - also get timing data (likely empty but shown for completeness) -set timing_data [job $jobid timing] - -# Open the file for writing -set fileId [open $output_filename "w"] - -# Write to the file -puts $fileId "TPC-H QUERY EXECUTION RESULTS FOR ORACLE" -puts $fileId "==========================================" -puts $fileId "" -puts $fileId "PERFORMANCE METRICS" -puts $fileId "-------------------" -puts $fileId "Total Elapsed Time: $elapsed_seconds seconds" -puts $fileId "Scale Factor: $scale_factor" -puts $fileId "QphH@$scale_factor: $qphh" -puts $fileId "" -puts $fileId "PER-QUERY TIMING (SECONDS)" -puts $fileId "-------------------------" - -# Sort queries numerically and display -set query_nums [lsort -integer [array names query_times_array]] -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts $fileId [format "Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts $fileId "No per-query timing data found in logs" -} - -puts $fileId "" -puts $fileId "QUERY TIMING RESULTS (from job object)" -puts $fileId "---------------------------------------" -puts $fileId $timing_data -puts $fileId "" -puts $fileId "HAMMERDB RESULT SUMMARY" -puts $fileId "-----------------------" -puts $fileId $result_string - -# Close the file -close $fileId - -# Print the output to the console -set output [exec cat $output_filename] -puts $output - -# Also output the QphH metric in a parseable format for aggregation -puts "" -puts "=== TPC-H PERFORMANCE SUMMARY ===" -puts "Elapsed Time: $elapsed_seconds seconds" -puts "QphH@$scale_factor: $qphh" -puts "" -puts "Per-Query Timing:" -if {[llength $query_nums] > 0} { - foreach query_num $query_nums { - set query_time $query_times_array($query_num) - puts [format " Query %2d: %8.3f seconds" $query_num $query_time] - } -} else { - puts " No per-query data available" -} -puts "=================================" diff --git a/src/hammerdb_scale/chart/Chart.yaml b/src/hammerdb_scale/chart/Chart.yaml index ca9252b..c463a31 100644 --- a/src/hammerdb_scale/chart/Chart.yaml +++ b/src/hammerdb_scale/chart/Chart.yaml @@ -2,8 +2,8 @@ apiVersion: v2 name: hammerdb-scale description: Modular Helm chart for parallel HammerDB scale testing across multiple databases (SQL Server, PostgreSQL, Oracle, MySQL) type: application -version: 2.0.2 -appVersion: "5.0" +version: 2.0.4 +appVersion: "6.0" keywords: - hammerdb - benchmark diff --git a/src/hammerdb_scale/chart/scripts/oracle/rman_backup.sh b/src/hammerdb_scale/chart/scripts/oracle/rman_backup.sh new file mode 100755 index 0000000..7100d50 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/oracle/rman_backup.sh @@ -0,0 +1,267 @@ +#!/bin/bash +# ============================================================================ +# Oracle RMAN Backup Script +# ============================================================================ +# Performs a cold (NOARCHIVELOG) RMAN backup across multiple Oracle hosts. +# Shuts down each database, backs up in MOUNT mode, then reopens. +# +# Usage: +# ./rman_backup.sh # backup all 8 hosts in parallel +# ./rman_backup.sh 01 04 # backup only oracle-01 and oracle-04 +# ./rman_backup.sh --sequential # backup all hosts one at a time +# ./rman_backup.sh --sequential 01 04 # sequential backup of specific hosts +# +# Prerequisites: +# - sshpass installed on the runner +# - NFS mount at /oracle-backup on all hosts +# - Oracle user SSH access (password: Osmium76) +# - Database in NOARCHIVELOG mode +# ============================================================================ + +# ── Configuration ────────────────────────────────────────────────────────── +declare -A HOST_MAP=( + [01]="10.21.227.45" + [02]="10.21.227.46" + [03]="10.21.227.47" + [04]="10.21.227.48" + [05]="10.21.227.49" + [06]="10.21.227.52" + [07]="10.21.227.53" + [08]="10.21.227.54" +) + +SSH_USER="oracle" +SSH_PASS="Osmium76" +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" +BACKUP_BASE="/oracle-backup" +CHANNELS=32 +SECTION_SIZE="4G" +FILESPERSET=1 + +# ── Parse arguments ─────────────────────────────────────────────────────── +SEQUENTIAL=false +TARGETS=() + +for arg in "$@"; do + if [[ "$arg" == "--sequential" ]]; then + SEQUENTIAL=true + elif [[ -n "${HOST_MAP[$arg]+x}" ]]; then + TARGETS+=("$arg") + else + echo "ERROR: Unknown argument '$arg'. Use host numbers (01-08) or --sequential." + exit 1 + fi +done + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + TARGETS=(01 02 03 04 05 06 07 08) +fi + +# ── Functions ────────────────────────────────────────────────────────────── + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# Write the remote backup script to a local temp file, scp it, execute it. +# This avoids all heredoc/quoting issues over SSH. +run_backup() { + local host_num=$1 + local ip="${HOST_MAP[$host_num]}" + local backup_path="${BACKUP_BASE}/oracle-${host_num}" + local local_tmp=$(mktemp /tmp/rman_backup_${host_num}_XXXX.sh) + local start_epoch=$(date '+%s') + + log "oracle-${host_num} (${ip}): Starting backup" + + # ── Build the remote script ── + cat > "$local_tmp" << 'SCRIPTEOF' +#!/bin/bash +source ~/.bash_profile + +BACKUP_PATH="__BACKUP_PATH__" +CHANNELS=__CHANNELS__ +SECTION_SIZE="__SECTION_SIZE__" +FILESPERSET=__FILESPERSET__ + +# Clean and prepare backup directory on NFS +mkdir -p "${BACKUP_PATH}" +rm -rf "${BACKUP_PATH}"/* + +# Build RMAN command file +RMAN_CMD="/tmp/rman_backup_$$.rmn" + +cat > "${RMAN_CMD}" << RMANEOF +CONNECT TARGET / + +SHUTDOWN IMMEDIATE; +STARTUP MOUNT; + +CONFIGURE CONTROLFILE AUTOBACKUP ON; +CONFIGURE CONTROLFILE AUTOBACKUP FORMAT FOR DEVICE TYPE DISK TO '${BACKUP_PATH}/%F'; + +RUN { +RMANEOF + +# Allocate channels +for ((i=1; i<=CHANNELS; i++)); do + echo " ALLOCATE CHANNEL ch${i} DEVICE TYPE DISK FORMAT '${BACKUP_PATH}/%U';" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF + + BACKUP + SECTION SIZE ${SECTION_SIZE} + FILESPERSET ${FILESPERSET} + DATABASE + TAG 'FULL_DB_BACKUP'; + + BACKUP CURRENT CONTROLFILE TAG 'CONTROLFILE_BACKUP'; + BACKUP SPFILE TAG 'SPFILE_BACKUP'; + +RMANEOF + +# Release channels +for ((i=1; i<=CHANNELS; i++)); do + echo " RELEASE CHANNEL ch${i};" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF +} + +ALTER DATABASE OPEN; +ALTER PLUGGABLE DATABASE ALL OPEN; + +EXIT; +RMANEOF + +# Run RMAN +RMAN_LOG="${BACKUP_PATH}/rman_backup_$(date '+%Y%m%d_%H%M%S').log" + +echo "START_EPOCH=$(date '+%s')" + +rman cmdfile="${RMAN_CMD}" log="${RMAN_LOG}" +RMAN_EXIT=$? + +rm -f "${RMAN_CMD}" + +# Report backup size +BACKUP_BYTES=$(du -sb "${BACKUP_PATH}" 2>/dev/null | awk '{print $1}') +echo "BACKUP_BYTES=${BACKUP_BYTES:-0}" + +echo "END_EPOCH=$(date '+%s')" +echo "RMAN_EXIT=${RMAN_EXIT}" + +exit ${RMAN_EXIT} +SCRIPTEOF + + # Substitute placeholders + sed -i "s|__BACKUP_PATH__|${backup_path}|g" "$local_tmp" + sed -i "s|__CHANNELS__|${CHANNELS}|g" "$local_tmp" + sed -i "s|__SECTION_SIZE__|${SECTION_SIZE}|g" "$local_tmp" + sed -i "s|__FILESPERSET__|${FILESPERSET}|g" "$local_tmp" + + # Copy script to remote host + sshpass -p "$SSH_PASS" scp $SSH_OPTS "$local_tmp" "${SSH_USER}@${ip}:/tmp/rman_backup_run.sh" 2>/dev/null + if [[ $? -ne 0 ]]; then + log "oracle-${host_num} (${ip}): FAILED — could not copy script" + rm -f "$local_tmp" + return 1 + fi + + # Execute on remote host + local output + output=$(sshpass -p "$SSH_PASS" ssh $SSH_OPTS "${SSH_USER}@${ip}" \ + "chmod +x /tmp/rman_backup_run.sh && /tmp/rman_backup_run.sh && rm -f /tmp/rman_backup_run.sh" 2>&1) + local exit_code=$? + + rm -f "$local_tmp" + + local end_epoch=$(date '+%s') + local duration=$((end_epoch - start_epoch)) + + # Parse metrics from output + local backup_bytes=$(echo "$output" | grep '^BACKUP_BYTES=' | tail -1 | cut -d= -f2) + local size_gb="0" + if [[ -n "$backup_bytes" && "$backup_bytes" -gt 0 ]] 2>/dev/null; then + size_gb=$(echo "scale=2; ${backup_bytes} / 1073741824" | bc 2>/dev/null || echo "0") + fi + + local throughput="N/A" + if [[ "$duration" -gt 0 && "$size_gb" != "0" ]]; then + throughput=$(echo "scale=2; (${size_gb} * 1024) / ${duration}" | bc 2>/dev/null || echo "N/A") + fi + + if [[ $exit_code -eq 0 ]]; then + log "oracle-${host_num} (${ip}): SUCCESS — ${duration}s, ${size_gb} GB, ${throughput} MB/s" + else + log "oracle-${host_num} (${ip}): FAILED (exit code ${exit_code})" + echo "$output" | grep -iE "ORA-|RMAN-|error|fail" | tail -10 + fi + + return $exit_code +} + +# ── Main ─────────────────────────────────────────────────────────────────── + +echo "=========================================" +echo "Oracle RMAN Backup" +echo "=========================================" +echo "Mode: $(if $SEQUENTIAL; then echo Sequential; else echo Parallel; fi)" +echo "Targets: ${TARGETS[*]}" +echo "Channels: ${CHANNELS}" +echo "NFS path: ${BACKUP_BASE}" +echo "Start: $(date '+%Y-%m-%d %H:%M:%S')" +echo "=========================================" +echo "" + +OVERALL_START=$(date '+%s') +PIDS=() +RESULTS=() +FAILED=0 + +if $SEQUENTIAL; then + for num in "${TARGETS[@]}"; do + if run_backup "$num"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + done +else + for num in "${TARGETS[@]}"; do + run_backup "$num" & + PIDS+=("$!:${num}") + done + + for entry in "${PIDS[@]}"; do + pid="${entry%%:*}" + num="${entry##*:}" + if wait "$pid"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + done +fi + +OVERALL_END=$(date '+%s') +OVERALL_DURATION=$((OVERALL_END - OVERALL_START)) + +echo "" +echo "=========================================" +echo "Backup Summary" +echo "=========================================" +printf "Total time: %ds (%s)\n" "$OVERALL_DURATION" "$(date -d@${OVERALL_DURATION} -u +%H:%M:%S 2>/dev/null || echo "${OVERALL_DURATION}s")" +echo "Targets: ${#TARGETS[@]}" +echo "Succeeded: $((${#TARGETS[@]} - FAILED))" +echo "Failed: ${FAILED}" +echo "" +for r in "${RESULTS[@]}"; do + echo " $r" +done +echo "=========================================" + +exit $FAILED diff --git a/src/hammerdb_scale/chart/scripts/oracle/rman_restore.sh b/src/hammerdb_scale/chart/scripts/oracle/rman_restore.sh new file mode 100755 index 0000000..7a38f8c --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/oracle/rman_restore.sh @@ -0,0 +1,391 @@ +#!/bin/bash +# ============================================================================ +# Oracle RMAN Restore Script +# ============================================================================ +# Restores a cold RMAN backup from the NFS share to an Oracle host. +# +# Usage: +# ./rman_restore.sh 04 # restore oracle-04 from its own backup +# ./rman_restore.sh 04 --from 01 # restore oracle-04 using oracle-01's backup +# ./rman_restore.sh 03 04 05 # restore multiple hosts from own backups +# +# Redirected restore (--from): +# Copies backup pieces from the source host's NFS dir to the target, +# then uses RMAN DUPLICATE to clone the database. This works across +# different DBIDs because DUPLICATE creates a new database from the +# backup rather than trying to restore into the existing controlfile. +# +# NOTE: Redirected restore requires the source database to be accessible +# (for RMAN to read the backup metadata). If the source is down, use +# a normal restore from the target's own backup instead. +# +# Prerequisites: +# - sshpass installed on the runner +# - NFS mount at /oracle-backup on all hosts +# - Oracle user SSH access (password: Osmium76) +# - Backup exists in /oracle-backup/oracle-NN/ +# - Same ASM diskgroup layout (+DATA01, +REDO01) on all hosts +# ============================================================================ + +# ── Configuration ────────────────────────────────────────────────────────── +declare -A HOST_MAP=( + [01]="10.21.227.45" + [02]="10.21.227.46" + [03]="10.21.227.47" + [04]="10.21.227.48" + [05]="10.21.227.49" + [06]="10.21.227.52" + [07]="10.21.227.53" + [08]="10.21.227.54" +) + +SSH_USER="oracle" +SSH_PASS="Osmium76" +SSH_OPTS="-o StrictHostKeyChecking=no -o ConnectTimeout=10" +BACKUP_BASE="/oracle-backup" +CHANNELS=32 + +# ── Parse arguments ─────────────────────────────────────────────────────── +FROM_HOST="" +TARGETS=() + +while [[ $# -gt 0 ]]; do + case "$1" in + --from) + FROM_HOST="$2" + if [[ -z "${HOST_MAP[$FROM_HOST]+x}" ]]; then + echo "ERROR: Invalid source host '$FROM_HOST'" + exit 1 + fi + shift 2 + ;; + *) + if [[ -n "${HOST_MAP[$1]+x}" ]]; then + TARGETS+=("$1") + else + echo "ERROR: Unknown argument '$1'. Use host numbers (01-08) or --from NN." + exit 1 + fi + shift + ;; + esac +done + +if [[ ${#TARGETS[@]} -eq 0 ]]; then + echo "Usage: $0 [host_num ...] [--from source_host_num]" + echo "" + echo "Examples:" + echo " $0 04 # restore oracle-04 from its own backup" + echo " $0 04 --from 01 # restore oracle-04 using oracle-01's backup" + echo " $0 03 04 05 # restore multiple hosts from own backups" + exit 1 +fi + +if [[ -n "$FROM_HOST" && ${#TARGETS[@]} -gt 1 ]]; then + echo "ERROR: --from can only be used with a single target host" + exit 1 +fi + +# ── Functions ────────────────────────────────────────────────────────────── + +log() { + echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" +} + +# Standard restore: restore the host's own backup +run_restore_own() { + local target_num=$1 + local target_ip="${HOST_MAP[$target_num]}" + local backup_path="${BACKUP_BASE}/oracle-${target_num}" + local local_tmp=$(mktemp /tmp/rman_restore_${target_num}_XXXX.sh) + local start_epoch=$(date '+%s') + + log "oracle-${target_num} (${target_ip}): Restoring from own backup" + + cat > "$local_tmp" << 'SCRIPTEOF' +#!/bin/bash +source ~/.bash_profile + +BACKUP_PATH="__BACKUP_PATH__" +CHANNELS=__CHANNELS__ + +# Verify backup exists +if [[ ! -d "${BACKUP_PATH}" ]] || [[ -z "$(ls -A ${BACKUP_PATH}/ 2>/dev/null)" ]]; then + echo "ERROR: No backup found at ${BACKUP_PATH}" + exit 1 +fi + +echo "Backup files found: $(ls ${BACKUP_PATH}/ | wc -l)" + +# Build RMAN command file +RMAN_CMD="/tmp/rman_restore_$$.rmn" + +cat > "${RMAN_CMD}" << RMANEOF +CONNECT TARGET / + +SHUTDOWN ABORT; +STARTUP MOUNT; + +RUN { +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " ALLOCATE CHANNEL ch${i} DEVICE TYPE DISK;" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF + + RESTORE DATABASE; + RECOVER DATABASE; + +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " RELEASE CHANNEL ch${i};" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF +} + +ALTER DATABASE OPEN RESETLOGS; +ALTER PLUGGABLE DATABASE ALL OPEN; + +EXIT; +RMANEOF + +# Run RMAN +RMAN_LOG="${BACKUP_PATH}/rman_restore_$(date '+%Y%m%d_%H%M%S').log" +rman cmdfile="${RMAN_CMD}" log="${RMAN_LOG}" +RMAN_EXIT=$? +rm -f "${RMAN_CMD}" + +# Verify +sqlplus -s / as sysdba << SQLEOF +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 +SELECT 'DB_STATUS=' || status FROM v\$instance; +SELECT 'PDB_STATUS=' || name || '=' || open_mode FROM v\$pdbs; +SQLEOF + +echo "RMAN_EXIT=${RMAN_EXIT}" +exit ${RMAN_EXIT} +SCRIPTEOF + + sed -i "s|__BACKUP_PATH__|${backup_path}|g" "$local_tmp" + sed -i "s|__CHANNELS__|${CHANNELS}|g" "$local_tmp" + + sshpass -p "$SSH_PASS" scp $SSH_OPTS "$local_tmp" "${SSH_USER}@${target_ip}:/tmp/rman_restore_run.sh" 2>/dev/null + if [[ $? -ne 0 ]]; then + log "oracle-${target_num}: FAILED — could not copy script" + rm -f "$local_tmp" + return 1 + fi + + local output + output=$(sshpass -p "$SSH_PASS" ssh $SSH_OPTS "${SSH_USER}@${target_ip}" \ + "chmod +x /tmp/rman_restore_run.sh && /tmp/rman_restore_run.sh && rm -f /tmp/rman_restore_run.sh" 2>&1) + local exit_code=$? + + rm -f "$local_tmp" + + local end_epoch=$(date '+%s') + local duration=$((end_epoch - start_epoch)) + + if [[ $exit_code -eq 0 ]]; then + log "oracle-${target_num}: SUCCESS — ${duration}s" + echo "$output" | grep -E "^(DB_STATUS=|PDB_STATUS=)" + else + log "oracle-${target_num}: FAILED (exit code ${exit_code})" + echo "$output" | grep -iE "ORA-|RMAN-|error|fail" | tail -10 + fi + + return $exit_code +} + +# Redirected restore: copy backup from source, use RMAN DUPLICATE +run_restore_from() { + local target_num=$1 + local source_num=$FROM_HOST + local target_ip="${HOST_MAP[$target_num]}" + local source_ip="${HOST_MAP[$source_num]}" + local source_path="${BACKUP_BASE}/oracle-${source_num}" + local target_path="${BACKUP_BASE}/oracle-${target_num}" + local local_tmp=$(mktemp /tmp/rman_restore_${target_num}_XXXX.sh) + local start_epoch=$(date '+%s') + + log "oracle-${target_num} (${target_ip}): Redirected restore FROM oracle-${source_num} (${source_ip})" + + cat > "$local_tmp" << 'SCRIPTEOF' +#!/bin/bash +source ~/.bash_profile + +SOURCE_PATH="__SOURCE_PATH__" +TARGET_PATH="__TARGET_PATH__" +CHANNELS=__CHANNELS__ + +# Copy backup pieces from source to target directory +echo "Copying backup from ${SOURCE_PATH} to ${TARGET_PATH}..." +mkdir -p "${TARGET_PATH}" +rm -f "${TARGET_PATH}"/* +cp "${SOURCE_PATH}"/* "${TARGET_PATH}/" 2>/dev/null +FILE_COUNT=$(ls "${TARGET_PATH}" | wc -l) +echo "Copied ${FILE_COUNT} files" + +if [[ ${FILE_COUNT} -eq 0 ]]; then + echo "ERROR: No backup files copied" + exit 1 +fi + +# Shut down existing database +echo "Shutting down existing database..." +sqlplus -s / as sysdba << SQLEOF +SHUTDOWN ABORT; +SQLEOF + +# Find the controlfile autobackup in the backup set +CTLFILE=$(ls "${TARGET_PATH}"/c-*-* 2>/dev/null | head -1) +if [[ -z "${CTLFILE}" ]]; then + echo "ERROR: No controlfile autobackup found in ${TARGET_PATH}" + exit 1 +fi +echo "Found controlfile backup: ${CTLFILE}" + +# Build RMAN command file +RMAN_CMD="/tmp/rman_restore_$$.rmn" + +cat > "${RMAN_CMD}" << RMANEOF +CONNECT TARGET / + +STARTUP NOMOUNT; + +# Restore controlfile from source backup +RESTORE CONTROLFILE FROM '${CTLFILE}'; +ALTER DATABASE MOUNT; + +# Catalog all backup pieces in the target path +CATALOG START WITH '${TARGET_PATH}/' NOPROMPT; + +RUN { +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " ALLOCATE CHANNEL ch${i} DEVICE TYPE DISK;" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF + + RESTORE DATABASE; + RECOVER DATABASE; + +RMANEOF + +for ((i=1; i<=CHANNELS; i++)); do + echo " RELEASE CHANNEL ch${i};" >> "${RMAN_CMD}" +done + +cat >> "${RMAN_CMD}" << RMANEOF +} + +ALTER DATABASE OPEN RESETLOGS; +ALTER PLUGGABLE DATABASE ALL OPEN; + +EXIT; +RMANEOF + +# Run RMAN +RMAN_LOG="${TARGET_PATH}/rman_restore_$(date '+%Y%m%d_%H%M%S').log" +rman cmdfile="${RMAN_CMD}" log="${RMAN_LOG}" +RMAN_EXIT=$? +rm -f "${RMAN_CMD}" + +# Verify +sqlplus -s / as sysdba << SQLEOF +SET HEADING OFF FEEDBACK OFF PAGESIZE 0 +SELECT 'DB_STATUS=' || status FROM v\$instance; +SELECT 'PDB_STATUS=' || name || '=' || open_mode FROM v\$pdbs; +SQLEOF + +echo "RMAN_EXIT=${RMAN_EXIT}" +exit ${RMAN_EXIT} +SCRIPTEOF + + sed -i "s|__SOURCE_PATH__|${source_path}|g" "$local_tmp" + sed -i "s|__TARGET_PATH__|${target_path}|g" "$local_tmp" + sed -i "s|__CHANNELS__|${CHANNELS}|g" "$local_tmp" + + sshpass -p "$SSH_PASS" scp $SSH_OPTS "$local_tmp" "${SSH_USER}@${target_ip}:/tmp/rman_restore_run.sh" 2>/dev/null + if [[ $? -ne 0 ]]; then + log "oracle-${target_num}: FAILED — could not copy script" + rm -f "$local_tmp" + return 1 + fi + + local output + output=$(sshpass -p "$SSH_PASS" ssh $SSH_OPTS "${SSH_USER}@${target_ip}" \ + "chmod +x /tmp/rman_restore_run.sh && /tmp/rman_restore_run.sh && rm -f /tmp/rman_restore_run.sh" 2>&1) + local exit_code=$? + + rm -f "$local_tmp" + + local end_epoch=$(date '+%s') + local duration=$((end_epoch - start_epoch)) + + if [[ $exit_code -eq 0 ]]; then + log "oracle-${target_num}: SUCCESS — ${duration}s" + echo "$output" | grep -E "^(DB_STATUS=|PDB_STATUS=)" + else + log "oracle-${target_num}: FAILED (exit code ${exit_code})" + echo "$output" | grep -iE "ORA-|RMAN-|error|fail" | tail -15 + fi + + return $exit_code +} + +# ── Main ─────────────────────────────────────────────────────────────────── + +echo "=========================================" +echo "Oracle RMAN Restore" +echo "=========================================" +echo "Targets: ${TARGETS[*]}" +if [[ -n "$FROM_HOST" ]]; then + echo "Source: oracle-${FROM_HOST}" + echo "Method: Redirected restore (controlfile + catalog)" +else + echo "Method: Standard restore (own backup)" +fi +echo "Channels: ${CHANNELS}" +echo "NFS path: ${BACKUP_BASE}" +echo "Start: $(date '+%Y-%m-%d %H:%M:%S')" +echo "=========================================" +echo "" + +FAILED=0 +RESULTS=() + +for num in "${TARGETS[@]}"; do + if [[ -n "$FROM_HOST" ]]; then + if run_restore_from "$num"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + else + if run_restore_own "$num"; then + RESULTS+=("oracle-${num}: SUCCESS") + else + RESULTS+=("oracle-${num}: FAILED") + FAILED=$((FAILED + 1)) + fi + fi +done + +echo "" +echo "=========================================" +echo "Restore Summary" +echo "=========================================" +for r in "${RESULTS[@]}"; do + echo " $r" +done +echo "=========================================" + +exit $FAILED diff --git a/src/hammerdb_scale/chart/scripts/oracle/setup_redo.sql b/src/hammerdb_scale/chart/scripts/oracle/setup_redo.sql new file mode 100644 index 0000000..ed64dc0 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/oracle/setup_redo.sql @@ -0,0 +1,117 @@ +-- ============================================================================ +-- Oracle Redo Log Layout Setup +-- ============================================================================ +-- Creates a standardized redo log layout: +-- 12 groups x 3 members each x 4GB on +REDO01 +-- +-- Usage: +-- sqlplus / as sysdba @setup_redo.sql +-- +-- Prerequisites: +-- - ASM diskgroup +REDO01 must exist +-- - Database must be OPEN +-- - Run as SYSDBA from CDB$ROOT +-- +-- The script is idempotent: it will drop and recreate all groups. +-- It handles CURRENT/ACTIVE groups by cycling log switches. +-- ============================================================================ + +SET SERVEROUTPUT ON SIZE UNLIMITED +SET FEEDBACK OFF +SET ECHO OFF + +WHENEVER SQLERROR CONTINUE + +-- Configure online log destination +ALTER SYSTEM SET db_create_online_log_dest_1='+REDO01' SCOPE=BOTH; + +PROMPT +PROMPT ============================================================ +PROMPT Current redo log layout (BEFORE) +PROMPT ============================================================ + +SELECT group#, members, bytes/1024/1024 size_mb, status +FROM v$log ORDER BY group#; + +PROMPT +PROMPT ============================================================ +PROMPT Rebuilding redo logs: 12 groups x 3 members x 4GB on +REDO01 +PROMPT ============================================================ + +DECLARE + v_target_groups CONSTANT PLS_INTEGER := 12; + v_target_members CONSTANT PLS_INTEGER := 3; + v_target_size CONSTANT VARCHAR2(10) := '4G'; + v_target_dg CONSTANT VARCHAR2(20) := '+REDO01'; + v_status VARCHAR2(20); + v_attempts PLS_INTEGER; + v_max_group PLS_INTEGER; +BEGIN + -- Phase 1: Drop all existing groups (cycle switches for CURRENT/ACTIVE) + DBMS_OUTPUT.PUT_LINE('--- Phase 1: Dropping existing groups ---'); + + SELECT NVL(MAX(group#), 0) INTO v_max_group FROM v$log; + + FOR g IN 1..GREATEST(v_max_group, v_target_groups) LOOP + BEGIN + SELECT status INTO v_status FROM v$log WHERE group# = g; + EXCEPTION + WHEN NO_DATA_FOUND THEN + DBMS_OUTPUT.PUT_LINE('Group ' || g || ': does not exist, skipping'); + CONTINUE; + END; + + -- If CURRENT or ACTIVE, cycle log switches until it becomes INACTIVE + IF v_status IN ('CURRENT', 'ACTIVE') THEN + v_attempts := 0; + WHILE v_status IN ('CURRENT', 'ACTIVE') AND v_attempts < 20 LOOP + EXECUTE IMMEDIATE 'ALTER SYSTEM SWITCH LOGFILE'; + EXECUTE IMMEDIATE 'ALTER SYSTEM CHECKPOINT'; + DBMS_LOCK.SLEEP(1); + SELECT status INTO v_status FROM v$log WHERE group# = g; + v_attempts := v_attempts + 1; + END LOOP; + + IF v_status IN ('CURRENT', 'ACTIVE') THEN + DBMS_OUTPUT.PUT_LINE('ERROR: Group ' || g || ' still ' || v_status || ' after ' || v_attempts || ' switches'); + CONTINUE; + END IF; + END IF; + + EXECUTE IMMEDIATE 'ALTER DATABASE DROP LOGFILE GROUP ' || g; + DBMS_OUTPUT.PUT_LINE('Group ' || g || ': dropped (' || v_status || ')'); + END LOOP; + + -- Phase 2: Create new groups with correct layout + DBMS_OUTPUT.PUT_LINE(''); + DBMS_OUTPUT.PUT_LINE('--- Phase 2: Creating ' || v_target_groups || ' groups ---'); + + FOR g IN 1..v_target_groups LOOP + -- Create group with 1 member (goes to db_create_online_log_dest_1 = +REDO01) + EXECUTE IMMEDIATE 'ALTER DATABASE ADD LOGFILE GROUP ' || g || ' SIZE ' || v_target_size; + + -- Add remaining members + FOR m IN 2..v_target_members LOOP + EXECUTE IMMEDIATE 'ALTER DATABASE ADD LOGFILE MEMBER ''' || v_target_dg || ''' TO GROUP ' || g; + END LOOP; + + DBMS_OUTPUT.PUT_LINE('Group ' || g || ': created with ' || v_target_members || ' members'); + END LOOP; + + DBMS_OUTPUT.PUT_LINE(''); + DBMS_OUTPUT.PUT_LINE('--- Done ---'); +END; +/ + +PROMPT +PROMPT ============================================================ +PROMPT Final redo log layout (AFTER) +PROMPT ============================================================ + +SELECT group#, members, bytes/1024/1024 size_mb, status +FROM v$log ORDER BY group#; + +COL member FORMAT A70 +SELECT group#, member FROM v$logfile ORDER BY group#, member; + +EXIT; diff --git a/src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl new file mode 100644 index 0000000..c712c21 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tprocc.tcl @@ -0,0 +1,79 @@ +#!/bin/tclsh +# Build the TPROC-C schema on PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set tprocc_user $::env(TPROCC_USER) +set tprocc_password $::env(TPROCC_PASSWORD) +set tprocc_database_name $::env(TPROCC_DATABASE_NAME) +set tprocc_build_virtual_users $::env(TPROCC_BUILD_VIRTUAL_USERS) +set warehouses $::env(WAREHOUSES) + +foreach var {USERNAME PASSWORD HOST PG_PORT TPROCC_DATABASE_NAME WAREHOUSES TPROCC_BUILD_VIRTUAL_USERS} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-C SCHEMA BUILD FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tprocc_database_name" +puts " Warehouses: $warehouses" +puts " Build Virtual Users: $tprocc_build_virtual_users" + +dbset db pg +dbset bm TPC-C + +# Connection +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +# The superuser creates the schema owner role and the database. +diset tpcc pg_superuser $username +diset tpcc pg_superuserpass $password +diset tpcc pg_defaultdbase postgres + +diset tpcc pg_user $tprocc_user +diset tpcc pg_pass $tprocc_password +diset tpcc pg_dbase $tprocc_database_name +diset tpcc pg_count_ware $warehouses +diset tpcc pg_num_vu $tprocc_build_virtual_users + +if {[info exists ::env(PG_TABLESPACE)] && $::env(PG_TABLESPACE) ne ""} { + diset tpcc pg_tspace $::env(PG_TABLESPACE) +} + +# Stored procedures must be chosen at build time: the driver script that runs +# later expects whichever form was built, so build and load must agree. +if {[info exists ::env(PG_STOREDPROCS)] && $::env(PG_STOREDPROCS) eq "true"} { + diset tpcc pg_storedprocs true + puts " Stored procedures: enabled" +} else { + diset tpcc pg_storedprocs false + puts " Stored procedures: disabled (prepared statements)" +} + +if {[info exists ::env(PG_PARTITION)] && $::env(PG_PARTITION) eq "true"} { + diset tpcc pg_partition true +} + +if {[info exists ::env(PG_VACUUM)] && $::env(PG_VACUUM) eq "true"} { + diset tpcc pg_vacuum true +} + +loadscript + +puts "Current TPROC-C configuration:" +print dict + +puts "Starting TPROC-C schema build..." +buildschema + +puts "TPROC-C SCHEMA BUILD COMPLETE" diff --git a/src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl new file mode 100644 index 0000000..a497984 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/build_schema_tproch.tcl @@ -0,0 +1,59 @@ +#!/bin/tclsh +# Build the TPROC-H schema on PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set tproch_user $::env(TPROCH_USER) +set tproch_password $::env(TPROCH_PASSWORD) +set tproch_database_name $::env(TPROCH_DATABASE_NAME) +set scale_factor $::env(TPROCH_SCALE_FACTOR) +set build_threads $::env(TPROCH_BUILD_THREADS) + +foreach var {USERNAME PASSWORD HOST PG_PORT TPROCH_DATABASE_NAME TPROCH_SCALE_FACTOR} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-H SCHEMA BUILD FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tproch_database_name" +puts " Scale Factor: $scale_factor" +puts " Build Threads: $build_threads" + +dbset db pg +dbset bm TPC-H + +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +diset tpch pg_tpch_superuser $username +diset tpch pg_tpch_superuserpass $password +diset tpch pg_tpch_defaultdbase postgres + +diset tpch pg_tpch_user $tproch_user +diset tpch pg_tpch_pass $tproch_password +diset tpch pg_tpch_dbase $tproch_database_name +diset tpch pg_scale_fact $scale_factor +diset tpch pg_num_tpch_threads $build_threads + +if {[info exists ::env(PG_TABLESPACE)] && $::env(PG_TABLESPACE) ne ""} { + diset tpch pg_tpch_tspace $::env(PG_TABLESPACE) +} + +loadscript + +puts "Current TPROC-H configuration:" +print dict + +puts "Starting TPROC-H schema build..." +buildschema + +puts "TPROC-H SCHEMA BUILD COMPLETE" diff --git a/src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl new file mode 100644 index 0000000..abdeae0 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/load_test_tprocc.tcl @@ -0,0 +1,110 @@ +#!/bin/tclsh +# Run the TPROC-C workload against PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set virtual_users $::env(VIRTUAL_USERS) +set tprocc_user $::env(TPROCC_USER) +set tprocc_password $::env(TPROCC_PASSWORD) +set tprocc_database_name $::env(TPROCC_DATABASE_NAME) +set rampup $::env(RAMPUP) +set duration $::env(DURATION) +set total_iterations $::env(TOTAL_ITERATIONS) +set tmpdir $::env(TMPDIR) +set tprocc_log_to_temp $::env(TPROCC_LOG_TO_TEMP) +set tprocc_use_transaction_counter $::env(TPROCC_USE_TRANSACTION_COUNTER) +set tprocc_timeprofile $::env(TPROCC_TIMEPROFILE) + +foreach var {USERNAME PASSWORD HOST PG_PORT VIRTUAL_USERS TPROCC_DATABASE_NAME} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-C LOAD TEST FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tprocc_database_name" +puts " Virtual Users: $virtual_users" +puts " Duration: $duration minutes" +puts " Rampup: $rampup minutes" + +dbset db pg +dbset bm TPC-C + +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +diset tpcc pg_superuser $username +diset tpcc pg_superuserpass $password +diset tpcc pg_defaultdbase postgres + +diset tpcc pg_user $tprocc_user +diset tpcc pg_pass $tprocc_password +diset tpcc pg_dbase $tprocc_database_name + +diset tpcc pg_driver timed +diset tpcc pg_total_iterations $total_iterations +diset tpcc pg_rampup $rampup +diset tpcc pg_duration $duration + +# Must match how the schema was built, or the driver calls procedures that +# do not exist. +if {[info exists ::env(PG_STOREDPROCS)] && $::env(PG_STOREDPROCS) eq "true"} { + diset tpcc pg_storedprocs true + puts " Stored procedures: enabled" +} else { + diset tpcc pg_storedprocs false + puts " Stored procedures: disabled (prepared statements)" +} + +if {[info exists ::env(TPROCC_ALLWAREHOUSE)] && $::env(TPROCC_ALLWAREHOUSE) eq "true"} { + diset tpcc pg_allwarehouse true +} + +if {$tprocc_timeprofile eq "true"} { + diset tpcc pg_timeprofile true +} + +# Keying and thinking time off: this measures maximum throughput, not a +# simulated user population. +diset tpcc pg_keyandthink false + +vuset logtotemp $tprocc_log_to_temp +loadscript + +puts "STARTING TPROC-C VIRTUAL USERS" +vuset vu $virtual_users +vucreate +puts "TEST STARTED" + +if {$tprocc_use_transaction_counter eq "true"} { + puts "Starting transaction counter..." + tcstart + tcstatus +} + +puts "About to run vurun command..." +set jobid [ vurun ] +puts "vurun completed with job ID: $jobid" +vudestroy + +if {$tprocc_use_transaction_counter eq "true"} { + puts "Stopping transaction counter..." + tcstop +} + +puts "Virtual users destroyed" +puts "TPROC-C LOAD TEST COMPLETE" + +puts "Creating output file at: $tmpdir/postgres_tprocc" +set of [ open $tmpdir/postgres_tprocc w ] +puts $of $jobid +close $of +puts "Job ID $jobid written to $tmpdir/postgres_tprocc" diff --git a/src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl b/src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl new file mode 100644 index 0000000..04edf97 --- /dev/null +++ b/src/hammerdb_scale/chart/scripts/postgres/load_test_tproch.tcl @@ -0,0 +1,73 @@ +#!/bin/tclsh +# Run the TPROC-H query set against PostgreSQL. + +set username $::env(USERNAME) +set password $::env(PASSWORD) +set host $::env(HOST) +set pg_port $::env(PG_PORT) + +set tproch_user $::env(TPROCH_USER) +set tproch_password $::env(TPROCH_PASSWORD) +set tproch_database_name $::env(TPROCH_DATABASE_NAME) +set scale_factor $::env(TPROCH_SCALE_FACTOR) +set virtual_users $::env(TPROCH_VIRTUAL_USERS) +set total_querysets $::env(TPROCH_TOTAL_QUERYSETS) +set tmpdir $::env(TMPDIR) + +foreach var {USERNAME PASSWORD HOST PG_PORT TPROCH_DATABASE_NAME TPROCH_VIRTUAL_USERS} { + if {![info exists ::env($var)] || $::env($var) eq ""} { + puts "Error: Environment variable $var is not set or empty" + exit 1 + } +} + +puts "SETTING UP TPROC-H LOAD TEST FOR POSTGRESQL" +puts " Host: $host:$pg_port" +puts " Database: $tproch_database_name" +puts " Scale Factor: $scale_factor" +puts " Virtual Users: $virtual_users" +puts " Query Sets: $total_querysets" + +dbset db pg +dbset bm TPC-H + +diset connection pg_host $host +diset connection pg_port $pg_port +if {[info exists ::env(PG_SSLMODE)]} { + diset connection pg_sslmode $::env(PG_SSLMODE) +} + +diset tpch pg_tpch_superuser $username +diset tpch pg_tpch_superuserpass $password +diset tpch pg_tpch_defaultdbase postgres + +diset tpch pg_tpch_user $tproch_user +diset tpch pg_tpch_pass $tproch_password +diset tpch pg_tpch_dbase $tproch_database_name +diset tpch pg_scale_fact $scale_factor +diset tpch pg_total_querysets $total_querysets + +if {[info exists ::env(PG_MAX_PARALLEL_WORKERS)]} { + diset tpch pg_degree_of_parallel $::env(PG_MAX_PARALLEL_WORKERS) +} + +# TPROC-H writes per-query timings to the temp log, which the parse phase reads. +vuset logtotemp 1 +loadscript + +puts "STARTING TPROC-H VIRTUAL USERS" +vuset vu $virtual_users +vucreate +puts "TEST STARTED" + +set jobid [ vurun ] +puts "vurun completed with job ID: $jobid" +vudestroy + +puts "TPROC-H LOAD TEST COMPLETE" + +puts "Creating output file at: $tmpdir/postgres_tproch" +set of [ open $tmpdir/postgres_tproch w ] +puts $of $jobid +close $of +puts "Job ID $jobid written to $tmpdir/postgres_tproch" diff --git a/scripts/oracle/parse_output_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl similarity index 60% rename from scripts/oracle/parse_output_tprocc.tcl rename to src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl index 28835a2..c0cef60 100644 --- a/scripts/oracle/parse_output_tprocc.tcl +++ b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tprocc.tcl @@ -1,4 +1,5 @@ -# Procedure to get the job ID from the output file +# Emit TPROC-C results for the job recorded by load_test_tprocc.tcl. + proc getjobid {filename} { set fd [open $filename r] set jobid [lindex [split [gets $fd] =] 1] @@ -6,17 +7,8 @@ proc getjobid {filename} { return $jobid } -# Procedure to get the output from the output file -proc getoutput {filename} { - set fd [open $filename r] - set output [read $fd] - close $fd - return $output -} - -# Main script execution set tmpdir $::env(TMPDIR) -set ::outputfile $tmpdir/oracle_tprocc +set ::outputfile $tmpdir/postgres_tprocc set filename $::outputfile set jobid [getjobid $filename] @@ -25,11 +17,8 @@ if {$jobid eq ""} { exit 1 } -# Set output as JSON jobs format JSON - -# Write output puts "TRANSACTION RESPONSE TIMES" puts [job $jobid timing] diff --git a/scripts/mssql/parse_output_tprocc.tcl b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl similarity index 51% rename from scripts/mssql/parse_output_tprocc.tcl rename to src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl index 561bad0..dad82b3 100644 --- a/scripts/mssql/parse_output_tprocc.tcl +++ b/src/hammerdb_scale/chart/scripts/postgres/parse_output_tproch.tcl @@ -1,4 +1,5 @@ -# Procedure to get the job ID from the output file +# Emit TPROC-H results for the job recorded by load_test_tproch.tcl. + proc getjobid {filename} { set fd [open $filename r] set jobid [lindex [split [gets $fd] =] 1] @@ -6,17 +7,8 @@ proc getjobid {filename} { return $jobid } -# Procedure to get the output from the output file -proc getoutput {filename} { - set fd [open $filename r] - set output [read $fd] - close $fd - return $output -} - -# Main script execution -set tmpdir /tmp -set ::outputfile $tmpdir/mssqls_tprocc +set tmpdir $::env(TMPDIR) +set ::outputfile $tmpdir/postgres_tproch set filename $::outputfile set jobid [getjobid $filename] @@ -25,16 +17,10 @@ if {$jobid eq ""} { exit 1 } -# Set output as JSON jobs format JSON - -# Write output puts "TRANSACTION RESPONSE TIMES" puts [job $jobid timing] -puts "TRANSACTION COUNT" -puts [jobs $jobid tcount] - puts "HAMMERDB RESULT" puts [jobs $jobid result] diff --git a/templates/configmap-oracle.yaml b/src/hammerdb_scale/chart/templates/configmap-postgres.yaml similarity index 60% rename from templates/configmap-oracle.yaml rename to src/hammerdb_scale/chart/templates/configmap-postgres.yaml index 7bc5ccd..83668cd 100644 --- a/templates/configmap-oracle.yaml +++ b/src/hammerdb_scale/chart/templates/configmap-postgres.yaml @@ -1,20 +1,20 @@ -{{- $hasOracle := false -}} +{{- $hasPostgreSQL := false -}} {{- range .Values.targets -}} - {{- if eq .type "oracle" -}} - {{- $hasOracle = true -}} + {{- if eq .type "postgres" -}} + {{- $hasPostgreSQL = true -}} {{- end -}} {{- end -}} -{{- if $hasOracle }} +{{- if $hasPostgreSQL }} apiVersion: v1 kind: ConfigMap metadata: - name: {{ include "hammerdb-scale-test.fullname" . }}-scripts-oracle + name: {{ include "hammerdb-scale-test.fullname" . }}-scripts-postgres labels: {{- include "hammerdb-scale-test.labels" . | nindent 4 }} - hammerdb.io/database-type: oracle + hammerdb.io/database-type: postgres data: # HammerDB TCL scripts - {{- range $path, $_ := .Files.Glob "scripts/oracle/*.tcl" }} + {{- range $path, $_ := .Files.Glob "scripts/postgres/*.tcl" }} {{ base $path }}: |- {{ $.Files.Get $path | indent 4 }} {{- end }} diff --git a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml index 60bcbe0..8abefd0 100644 --- a/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml +++ b/src/hammerdb_scale/chart/templates/job-hammerdb-worker.yaml @@ -51,10 +51,26 @@ spec: spec: hostNetwork: {{ $.Values.global.hostNetwork | default false }} restartPolicy: Never + {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} + # Satisfies the "restricted" Pod Security Standard so the chart is + # portable to plain Kubernetes clusters that enforce it. runAsUser is + # deliberately NOT set: OpenShift's SCC assigns an arbitrary UID from the + # namespace range, and hardcoding one conflicts with that. + securityContext: + runAsNonRoot: true + seccompProfile: + type: RuntimeDefault + {{- end }} containers: - name: hammerdb image: "{{ $.Values.global.image.repository }}:{{ $.Values.global.image.tag }}" imagePullPolicy: {{ $.Values.global.image.pullPolicy }} + {{- if ne (($.Values.global.securityContext).enabled | toString) "false" }} + securityContext: + allowPrivilegeEscalation: false + capabilities: + drop: ["ALL"] + {{- end }} env: # Test identification - name: TEST_RUN_ID @@ -70,11 +86,27 @@ spec: - name: DATABASE_TYPE value: {{ $target.type | quote }} - # Database connection + # Database connection. Credentials come from a Secret when enabled so + # they are not readable via `kubectl describe job`. + {{- $useSecrets := ne (($.Values.kubernetes).useSecrets | toString) "false" }} + {{- $secretName := printf "%s-credentials" (include "hammerdb-scale-test.fullname" $) }} + {{- if $useSecrets }} + - name: USERNAME + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-username + - name: PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-password + {{- else }} - name: USERNAME value: {{ $target.username | quote }} - name: PASSWORD value: {{ $target.password | quote }} + {{- end }} - name: HOST value: {{ $target.host | quote }} @@ -124,16 +156,83 @@ spec: value: {{ $oracleConfig.tempTablespace }} - name: TPROCC_USER value: {{ $oracleConfig.tproccUser }} + {{- if $useSecrets }} + - name: TPROCC_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tprocc-password + {{- else }} - name: TPROCC_PASSWORD value: {{ $oracleConfig.tproccPassword }} + {{- end }} - name: TPROCH_USER value: {{ $oracleConfig.tprochUser }} + {{- if $useSecrets }} + - name: TPROCH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tproch-password + {{- else }} - name: TPROCH_PASSWORD value: {{ $oracleConfig.tprochPassword }} + {{- end }} - name: TPROCH_DEGREE_OF_PARALLEL value: {{ $oracleConfig.degreeOfParallel | quote }} {{- end }} + {{- if eq $target.type "postgres" }} + # PostgreSQL connection config + - name: PG_PORT + value: {{ $target.postgres.port | default 5432 | quote }} + - name: PG_SSLMODE + value: {{ $target.postgres.sslmode | default "prefer" | quote }} + {{- if $target.postgres.tablespace }} + - name: PG_TABLESPACE + value: {{ $target.postgres.tablespace | quote }} + {{- end }} + {{- if eq $.Values.testRun.benchmark "tprocc" }} + - name: TPROCC_USER + value: {{ $target.postgres.tprocc.user | default "tpcc" | quote }} + - name: TPROCC_DATABASE_NAME + value: {{ $target.postgres.tprocc.database_name | default "tpcc" | quote }} + {{- if $useSecrets }} + - name: TPROCC_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tprocc-password + {{- else }} + - name: TPROCC_PASSWORD + value: {{ $target.postgres.tprocc.password | default "tpcc" | quote }} + {{- end }} + - name: PG_STOREDPROCS + value: {{ $target.postgres.tprocc.stored_procedures | default true | quote }} + - name: PG_PARTITION + value: {{ $target.postgres.tprocc.partition | default false | quote }} + - name: PG_VACUUM + value: {{ $target.postgres.tprocc.vacuum | default false | quote }} + {{- else }} + - name: TPROCH_USER + value: {{ $target.postgres.tproch.user | default "tpch" | quote }} + - name: TPROCH_DATABASE_NAME + value: {{ $target.postgres.tproch.database_name | default "tpch" | quote }} + {{- if $useSecrets }} + - name: TPROCH_PASSWORD + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: target-{{ $index }}-tproch-password + {{- else }} + - name: TPROCH_PASSWORD + value: {{ $target.postgres.tproch.password | default "tpch" | quote }} + {{- end }} + - name: PG_MAX_PARALLEL_WORKERS + value: {{ $target.postgres.tproch.max_parallel_workers | default 8 | quote }} + {{- end }} + {{- end }} + {{- if eq $.Values.testRun.benchmark "tprocc" }} # TPC-C Configuration (from merged config) {{- if eq $target.type "mssql" }} @@ -157,7 +256,9 @@ spec: - name: DURATION value: {{ $tproccConfig.duration | quote }} - name: TOTAL_ITERATIONS - value: {{ $tproccConfig.total_iterations | quote }} + # printf %.0f avoids Go rendering large ints in scientific notation + # (10000000 -> "1e+07"), which the TCL scripts cannot parse. + value: {{ printf "%.0f" (float64 $tproccConfig.total_iterations) | quote }} - name: TPROCC_LOG_TO_TEMP value: "0" - name: TPROCC_USE_TRANSACTION_COUNTER @@ -205,8 +306,16 @@ spec: value: {{ if eq $index 0 }}"true"{{ else }}"false"{{ end }} - name: PURE_HOST value: {{ $.Values.pureStorage.host | quote }} + {{- if $useSecrets }} + - name: PURE_API_TOKEN + valueFrom: + secretKeyRef: + name: {{ $secretName }} + key: pure-api-token + {{- else }} - name: PURE_API_TOKEN value: {{ $.Values.pureStorage.apiToken | quote }} + {{- end }} {{- if $.Values.pureStorage.volume }} - name: PURE_VOLUME value: {{ $.Values.pureStorage.volume | quote }} @@ -230,7 +339,10 @@ spec: volumeMounts: - name: scripts - mountPath: /opt/HammerDB-5.0/scripts + # entrypoint.sh searches for its scripts, so this need not match + # the image's HAMMERDB_HOME exactly. Set global.hammerdbHome to pin + # it for an image built with a different HAMMERDB_VERSION. + mountPath: {{ printf "%s/scripts" ($.Values.global.hammerdbHome | default "/opt/HammerDB-6.0") }} volumes: - name: scripts diff --git a/src/hammerdb_scale/chart/templates/secret-credentials.yaml b/src/hammerdb_scale/chart/templates/secret-credentials.yaml new file mode 100644 index 0000000..303bd32 --- /dev/null +++ b/src/hammerdb_scale/chart/templates/secret-credentials.yaml @@ -0,0 +1,41 @@ +{{- if ne (($.Values.kubernetes).useSecrets | toString) "false" }} +{{- /* +Database credentials for each target. + +Without this, passwords are plain `value:` entries in the Job spec and anyone +with read access to the namespace can recover them with `kubectl describe job`. +Here they become secretKeyRef lookups instead. + +One Secret per release holds every target's credentials, keyed by target index, +so a single object is created and removed with the release. +*/ -}} +apiVersion: v1 +kind: Secret +metadata: + name: {{ include "hammerdb-scale-test.fullname" . }}-credentials + labels: + {{- include "hammerdb-scale-test.labels" . | nindent 4 }} + {{- if .Values.extraLabels }} + {{- range $key, $val := .Values.extraLabels }} + {{ $key }}: {{ $val | quote }} + {{- end }} + {{- end }} +type: Opaque +stringData: + {{- range $index, $target := .Values.targets }} + {{- $oracleConfig := include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" $target) | fromYaml }} + target-{{ $index }}-username: {{ $target.username | quote }} + target-{{ $index }}-password: {{ $target.password | quote }} + {{- if eq $target.type "oracle" }} + target-{{ $index }}-tprocc-password: {{ $oracleConfig.tproccPassword | quote }} + target-{{ $index }}-tproch-password: {{ $oracleConfig.tprochPassword | quote }} + {{- end }} + {{- if eq $target.type "postgres" }} + target-{{ $index }}-tprocc-password: {{ $target.postgres.tprocc.password | default "tpcc" | quote }} + target-{{ $index }}-tproch-password: {{ $target.postgres.tproch.password | default "tpch" | quote }} + {{- end }} + {{- end }} + {{- if .Values.pureStorage.enabled }} + pure-api-token: {{ .Values.pureStorage.apiToken | quote }} + {{- end }} +{{- end }} diff --git a/src/hammerdb_scale/clean/resources.py b/src/hammerdb_scale/clean/resources.py index 27cd65c..d376aa1 100644 --- a/src/hammerdb_scale/clean/resources.py +++ b/src/hammerdb_scale/clean/resources.py @@ -9,6 +9,48 @@ from hammerdb_scale.results.storage import results_exist +def clean_container_resources( + backend, + test_id: str | None = None, + everything: bool = False, + force: bool = False, + results_dir=None, +) -> None: + """Remove containers created by the container backend.""" + from pathlib import Path + + if results_dir is None: + results_dir = Path("./results") + + workloads = backend.list_workloads(test_id) if test_id else [] + if everything: + console.print("\nRemoving all hammerdb-scale containers.") + elif not workloads: + console.print("No containers found to clean.") + return + else: + console.print(f"\nFound {len(workloads)} container(s):") + for w in workloads: + console.print(f" {w.name} ({w.status})") + + if test_id and not results_exist(test_id, results_dir) and not force: + print_warning( + f"Results for test '{test_id}' have not been aggregated.\n" + f" Run 'hammerdb-scale results --id {test_id}' first,\n" + f" or use --force to proceed without aggregating." + ) + if not typer.confirm("\nProceed anyway?"): + raise typer.Abort() + + if not force: + if not typer.confirm("\nRemove these containers?"): + raise typer.Abort() + + removed = backend.remove(test_id=test_id, everything=everything) + print_success(f"Removed {removed} container(s)") + console.print("\nContainers cleaned. Local results preserved in ./results/") + + def clean_resources( namespace: str, test_id: str | None = None, diff --git a/src/hammerdb_scale/cli.py b/src/hammerdb_scale/cli.py index 4d24764..6442afa 100644 --- a/src/hammerdb_scale/cli.py +++ b/src/hammerdb_scale/cli.py @@ -19,7 +19,6 @@ DEFAULT_NAMESPACE, VERSION, ConfigError, - get_chart_path, ) from hammerdb_scale.output import console, print_error, print_success, print_warning from rich.table import Table @@ -36,7 +35,9 @@ @app.callback() def main( - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file path."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file path." + ), verbose: bool = typer.Option(False, "-v", "--verbose", help="Verbose output."), ) -> None: """HammerDB-Scale: orchestrate parallel database benchmarks on Kubernetes.""" @@ -44,6 +45,61 @@ def main( _state["verbose"] = verbose +def _tool_version(tool_path: str, tool: str) -> str | None: + """Return a tool's version string, or None if it could not be determined. + + kubectl removed --short in 1.28, so asking for it fails outright on any + current install. Use the JSON output it has supported for years and fall + back to plain `version` for older builds and for helm. + """ + attempts: list[list[str]] = [] + if tool == "kubectl": + attempts.append([tool_path, "version", "--client", "-o", "json"]) + if tool in ("podman", "docker"): + # `podman version` prints a multi-line client/server block. + attempts.append([tool_path, "version", "--format", "{{.Client.Version}}"]) + attempts.append([tool_path, "--version"]) + attempts.append([tool_path, "version", "--short"]) + attempts.append([tool_path, "version"]) + + for args in attempts: + try: + result = subprocess.run(args, capture_output=True, text=True, timeout=10) + except (OSError, subprocess.SubprocessError): + continue + if result.returncode != 0 or not result.stdout.strip(): + continue + + text = result.stdout.strip() + if "-o" in args: + try: + import json as json_mod + + data = json_mod.loads(text) + git_version = data.get("clientVersion", {}).get("gitVersion") + if git_version: + return git_version + except (ValueError, AttributeError): + continue + else: + return text.splitlines()[0].strip() + return None + + +def _kubectl_context(kubectl_path: str) -> str: + """Current kubeconfig context, or 'unknown'.""" + try: + result = subprocess.run( + [kubectl_path, "config", "current-context"], + capture_output=True, + text=True, + timeout=10, + ) + return result.stdout.strip() if result.returncode == 0 else "unknown" + except (OSError, subprocess.SubprocessError): + return "unknown" + + @app.command() def version() -> None: """Show CLI and tooling versions.""" @@ -53,15 +109,10 @@ def version() -> None: # helm helm_path = shutil.which("helm") if helm_path: - try: - result = subprocess.run( - [helm_path, "version", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - console.print(f" helm {result.stdout.strip()}") - except Exception: + helm_version = _tool_version(helm_path, "helm") + if helm_version: + console.print(f" helm {helm_version}") + else: console.print( " helm [yellow]found but version check failed[/yellow]" ) @@ -73,23 +124,11 @@ def version() -> None: # kubectl kubectl_path = shutil.which("kubectl") if kubectl_path: - try: - result = subprocess.run( - [kubectl_path, "version", "--client", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - version_str = result.stdout.strip() - ctx_result = subprocess.run( - [kubectl_path, "config", "current-context"], - capture_output=True, - text=True, - timeout=10, - ) - ctx = ctx_result.stdout.strip() if ctx_result.returncode == 0 else "unknown" - console.print(f" kubectl {version_str} (context: {ctx})") - except Exception: + kubectl_version = _tool_version(kubectl_path, "kubectl") + ctx = _kubectl_context(kubectl_path) + if kubectl_version: + console.print(f" kubectl {kubectl_version} (context: {ctx})") + else: console.print( " kubectl [yellow]found but version check failed[/yellow]" ) @@ -99,6 +138,14 @@ def version() -> None: "(install from https://kubernetes.io/docs/tasks/tools/)" ) + # container runtime + for runtime in ("podman", "docker"): + runtime_path = shutil.which(runtime) + if runtime_path: + runtime_version = _tool_version(runtime_path, runtime) + if runtime_version: + console.print(f" {runtime:<15} {runtime_version}") + def _build_config_yaml( *, @@ -110,7 +157,8 @@ def _build_config_yaml( password: str, oracle_config: dict | None, warehouses: int = 100, - namespace: str = "hammerdb", + namespace: str | None = "hammerdb", + backend_str: str = "kubernetes", storage_metrics: dict | None = None, build_virtual_users: int = 4, load_virtual_users: int = 4, @@ -148,6 +196,20 @@ def _build_config_yaml( user: "tpch" # TPC-H schema owner (created during build) password: "{oracle_config["tproch"]["password"]}" degree_of_parallel: 8 # Oracle parallel query degree""" + elif db_type_str == "postgres": + db_defaults = """ postgres: + port: 5432 + sslmode: prefer # prefer|require|disable + tprocc: + database_name: tpcc # database created during build phase + user: tpcc # schema owner (created during build) + password: tpcc + stored_procedures: true # build and drive via stored procedures + tproch: + database_name: tpch # database created during build phase + user: tpch + password: tpch + max_parallel_workers: 8 # parallel workers for analytic queries""" else: db_defaults = """ mssql: port: 1433 @@ -222,6 +284,26 @@ def _build_config_yaml( # poll_interval: 5 # collection interval in seconds # verify_ssl: false""" + # Only the selected backend's settings are emitted. A container user who + # finds a Kubernetes block in their config reasonably concludes they need + # a cluster. + if backend_str == "kubernetes": + backend_section = f""" +# ============================================================================ +# KUBERNETES SETTINGS +# ============================================================================ +kubernetes: + namespace: {namespace or "hammerdb"} # namespace for benchmark jobs (must exist) + job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h)""" + else: + backend_section = """ +# ============================================================================ +# CONTAINER SETTINGS +# ============================================================================ +container: + runtime: auto # auto|podman|docker + # network: "" # attach workers to a named network""" + # Assemble full config return f"""# ============================================================================ # HammerDB-Scale Configuration @@ -238,8 +320,9 @@ def _build_config_yaml( # ============================================================================ name: {name} -description: "{db_type_str.upper()} {benchmark_str.upper()} benchmark — {len(hosts)} target{"s" if len(hosts) != 1 else ""}" +description: "{db_type_str.upper()} {benchmark_str.upper()} benchmark, {len(hosts)} target{"s" if len(hosts) != 1 else ""}" default_benchmark: {benchmark_str} +backend: {backend_str} # podman|docker|kubernetes # ============================================================================ # DATABASE TARGETS @@ -282,12 +365,7 @@ def _build_config_yaml( memory: "{lim_memory}" cpu: "{lim_cpu}" -# ============================================================================ -# KUBERNETES SETTINGS -# ============================================================================ -kubernetes: - namespace: {namespace} # namespace for benchmark jobs (must exist) - job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h) +{backend_section} {storage_section} """ @@ -419,10 +497,17 @@ def init( @app.command() def validate( - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file." + ), skip_connectivity: bool = typer.Option( False, "--skip-connectivity", help="Skip network connectivity checks." ), + from_cluster: bool = typer.Option( + False, + "--from-cluster", + help="Test connectivity from where the benchmark runs, not this machine.", + ), ) -> None: """Multi-layer validation with clear, actionable output.""" config_path = file or _state.get("file") @@ -481,52 +566,52 @@ def validate( for w in warnings: print_warning(w) - # Layer 4: Prerequisites + # Layer 4: Prerequisites for the configured backend console.print("\nChecking prerequisites...") - helm_path = shutil.which("helm") - if helm_path: - try: - result = subprocess.run( - [helm_path, "version", "--short"], - capture_output=True, - text=True, - timeout=10, + uses_kubernetes = config.backend.value == "kubernetes" + + if uses_kubernetes: + helm_path = shutil.which("helm") + if helm_path: + helm_version = _tool_version(helm_path, "helm") + print_success(f"helm found ({helm_version or 'version unknown'})") + else: + print_error("helm not found (install from https://helm.sh)") + errors_found = True + + kubectl_path = shutil.which("kubectl") + if kubectl_path: + kubectl_version = _tool_version(kubectl_path, "kubectl") + print_success(f"kubectl found ({kubectl_version or 'version unknown'})") + print_success(f"Current context: {_kubectl_context(kubectl_path)}") + else: + print_error( + "kubectl not found " + "(install from https://kubernetes.io/docs/tasks/tools/)" ) - print_success(f"helm found ({result.stdout.strip()})") - except Exception: - print_warning("helm found but version check failed") + errors_found = True else: - print_error("helm not found (install from https://helm.sh)") - errors_found = True + # Container backend: helm and kubectl are irrelevant. + from hammerdb_scale.runtime.container import ( + ContainerRuntimeError, + detect_runtime, + ) - kubectl_path = shutil.which("kubectl") - if kubectl_path: try: - result = subprocess.run( - [kubectl_path, "version", "--client", "--short"], - capture_output=True, - text=True, - timeout=10, - ) - ctx_result = subprocess.run( - [kubectl_path, "config", "current-context"], - capture_output=True, - text=True, - timeout=10, + requested = config.container.runtime.value + runtime = detect_runtime(None if requested == "auto" else requested) + runtime_path = shutil.which(runtime) + runtime_version = ( + _tool_version(runtime_path, runtime) if runtime_path else None ) - ctx = ctx_result.stdout.strip() if ctx_result.returncode == 0 else "unknown" - print_success(f"kubectl found ({result.stdout.strip()})") - print_success(f"Current context: {ctx}") - except Exception: - print_warning("kubectl found but version check failed") - else: - print_error( - "kubectl not found (install from https://kubernetes.io/docs/tasks/tools/)" - ) - errors_found = True + print_success(f"{runtime} found ({runtime_version or 'version unknown'})") + except ContainerRuntimeError as e: + print_error(str(e)) + errors_found = True # Layer 5: K8s access - if kubectl_path: + if uses_kubernetes and shutil.which("kubectl"): + kubectl_path = shutil.which("kubectl") console.print("\nChecking Kubernetes access...") ns = config.kubernetes.namespace try: @@ -543,36 +628,28 @@ def validate( f"Namespace '{ns}' does not exist. " f"It will be created during deployment." ) - except Exception: + except (OSError, subprocess.SubprocessError): print_warning("Could not check namespace") - try: - result = subprocess.run( - [kubectl_path, "auth", "can-i", "create", "jobs", "-n", ns], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0 and "yes" in result.stdout.lower(): - print_success("Can create Jobs in namespace") - else: - print_warning("Cannot verify Job creation permissions") - except Exception: - print_warning("Could not check permissions") + # Secrets are only needed when credentials go through one. + resources = ["jobs", "configmaps"] + if config.kubernetes.use_secrets: + resources.append("secrets") - try: - result = subprocess.run( - [kubectl_path, "auth", "can-i", "create", "configmaps", "-n", ns], - capture_output=True, - text=True, - timeout=10, - ) - if result.returncode == 0 and "yes" in result.stdout.lower(): - print_success("Can create ConfigMaps in namespace") - else: - print_warning("Cannot verify ConfigMap creation permissions") - except Exception: - print_warning("Could not check permissions") + for resource in resources: + try: + result = subprocess.run( + [kubectl_path, "auth", "can-i", "create", resource, "-n", ns], + capture_output=True, + text=True, + timeout=10, + ) + if result.returncode == 0 and "yes" in result.stdout.lower(): + print_success(f"Can create {resource} in namespace") + else: + print_warning(f"Cannot verify {resource} creation permissions") + except (OSError, subprocess.SubprocessError): + print_warning(f"Could not check {resource} permissions") # Layer 6: Database connectivity if not skip_connectivity: @@ -580,6 +657,15 @@ def validate( conn_failures = _check_connectivity(config) if conn_failures: errors_found = True + + if from_cluster: + if _check_connectivity_from_cluster(config, namespace=None): + errors_found = True + else: + console.print( + "\n[dim]Checked from this machine. The benchmark runs elsewhere; " + "use --from-cluster to test from there.[/dim]" + ) else: console.print("\n[dim]Skipping connectivity checks (--skip-connectivity)[/dim]") @@ -594,6 +680,44 @@ def validate( console.print(f"\nValidation complete: {warning_count} warning(s), 0 errors.") +def _check_connectivity_from_cluster( + config: HammerDBScaleConfig, namespace: Optional[str] +) -> int: + """Probe the databases from where the benchmark actually runs. + + Passing the workstation check proves nothing about the pod's network + position, so this repeats it from there. Returns the failure count. + """ + from hammerdb_scale.runtime import get_backend + from hammerdb_scale.runtime.preflight import ( + check_from_container, + check_from_kubernetes, + ) + + image_cfg = config.targets.defaults.image + image = f"{image_cfg.repository}:{image_cfg.tag}" + backend = get_backend(config, namespace) + + console.print(f"\nChecking connectivity from {backend.name}...") + try: + if backend.name == "kubernetes": + results = check_from_kubernetes(config, backend.namespace, image) + else: + results = check_from_container(config, backend.runtime, image) + except Exception as e: + print_error(f"Could not run the in-cluster check: {e}") + return 1 + + failures = 0 + for entry in results: + if entry.get("ok"): + print_success(f"{entry['name']} reachable from {backend.name}") + else: + print_error(f"{entry['name']} {entry.get('error', 'unreachable')}") + failures += 1 + return failures + + def _check_connectivity(config: HammerDBScaleConfig) -> int: """Check database connectivity for all targets in parallel. Returns failure count.""" from hammerdb_scale.config.defaults import expand_targets @@ -664,6 +788,34 @@ def check_target(target: dict) -> tuple[str, bool, str]: except Exception as e: return (name, False, f"{host}:{port} {e}") + elif db_type == "postgres": + pg_cfg = target.get("postgres", {}) + port = pg_cfg.get("port", 5432) + try: + import psycopg2 + + conn = psycopg2.connect( + host=host, + port=port, + user=username, + password=password, + dbname="postgres", + connect_timeout=10, + ) + with conn.cursor() as cur: + cur.execute("select version()") + server = cur.fetchone()[0].split(",")[0] + conn.close() + return (name, True, f"{host}:{port} Connected ({server})") + except ImportError: + return ( + name, + False, + "psycopg2 not installed. Install with: pip install psycopg2-binary", + ) + except Exception as e: + return (name, False, f"{host}:{port} {e}") + return (name, False, f"Unknown database type: {db_type}") failures = 0 @@ -699,7 +851,9 @@ def build( benchmark: Optional[str] = typer.Option( None, "--benchmark", help="tprocc or tproch." ), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file." + ), id: Optional[str] = typer.Option(None, "--id", help="Test ID override."), namespace: Optional[str] = typer.Option( None, "-n", "--namespace", help="Override namespace." @@ -710,15 +864,9 @@ def build( wait: bool = typer.Option(False, "--wait", help="Wait for all jobs to complete."), timeout: int = typer.Option(7200, "--timeout", help="Wait timeout in seconds."), ) -> None: - """Create database schemas. Wraps helm install with phase=build.""" - from hammerdb_scale.helm.deployer import helm_install - from hammerdb_scale.helm.values import generate_helm_values + """Create database schemas on every target.""" from hammerdb_scale.k8s.jobs import resolve_benchmark - from hammerdb_scale.k8s.naming import ( - generate_release_name, - generate_run_hash, - generate_test_id, - ) + from hammerdb_scale.k8s.naming import generate_test_id config_path = file or _state.get("file") config = load_config(discover_config_file(config_path)) @@ -730,8 +878,6 @@ def build( print_error(str(e)) raise typer.Exit(1) test_id = id or generate_test_id(config.name) - run_hash = generate_run_hash(config.name, test_id) - release_name = generate_release_name("build", run_hash) target_count = len(config.targets.hosts) from hammerdb_scale.output import print_banner @@ -747,27 +893,24 @@ def build( config.name, bm, target_count, f"SF {config.hammerdb.tproch.scale_factor}" ) - values = generate_helm_values(config, "build", bm, test_id) - chart_path = get_chart_path() - - console.print("Deploying jobs...") - result = helm_install(release_name, chart_path, ns, values, dry_run=dry_run) - + backend, names = _deploy( + config, ns, phase="build", benchmark=bm, test_id=test_id, dry_run=dry_run + ) if dry_run: - console.print(result.stdout) return - # Print deployed jobs - for i, host in enumerate(config.targets.hosts): - job_name = f"hdb-build-{i:02d}-{run_hash}" - print_success(f"{job_name} ({host.name})") + for name, host in zip(names, config.targets.hosts): + print_success(f"{name} ({host.name})") - console.print(f"\n{target_count} build jobs deployed to namespace '{ns}'.\n") + console.print(f"\n{target_count} build jobs deployed via {backend.name}.\n") console.print(f"Monitor progress: hammerdb-scale status --id {test_id}") console.print(f"View logs: hammerdb-scale logs --id {test_id}") if wait: - _wait_for_jobs(ns, test_id, "build", timeout) + from hammerdb_scale.runtime.resolve import wait_for_completion + + if not wait_for_completion(backend, test_id, "build", timeout): + raise typer.Exit(1) @app.command() @@ -778,7 +921,9 @@ def run( build_first: bool = typer.Option( False, "--build", help="Build schemas before running." ), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file." + ), id: Optional[str] = typer.Option(None, "--id", help="Test ID override."), namespace: Optional[str] = typer.Option( None, "-n", "--namespace", help="Override namespace." @@ -789,17 +934,12 @@ def run( 3600, "--timeout", help="Wait timeout seconds for run phase." ), ) -> None: - """Execute the benchmark. Wraps helm install with phase=load.""" + """Execute the benchmark workload against every target.""" from hammerdb_scale.constants import BUILD_TIMEOUT_DEFAULT - from hammerdb_scale.helm.deployer import helm_install, helm_uninstall - from hammerdb_scale.helm.values import generate_helm_values from hammerdb_scale.k8s.jobs import resolve_benchmark - from hammerdb_scale.k8s.naming import ( - generate_release_name, - generate_run_hash, - generate_test_id, - ) + from hammerdb_scale.k8s.naming import generate_test_id from hammerdb_scale.output import print_banner + from hammerdb_scale.runtime.resolve import wait_for_completion config_path = file or _state.get("file") config = load_config(discover_config_file(config_path)) @@ -811,22 +951,21 @@ def run( print_error(str(e)) raise typer.Exit(1) test_id = id or generate_test_id(config.name) - run_hash = generate_run_hash(config.name, test_id) target_count = len(config.targets.hosts) # --build: build schemas first if build_first: console.print("Building schemas first...\n") - build_release = generate_release_name("build", run_hash) - build_values = generate_helm_values(config, "build", bm, test_id) - chart_path = get_chart_path() - - helm_install(build_release, chart_path, ns, build_values, dry_run=dry_run) + build_backend, _ = _deploy( + config, ns, phase="build", benchmark=bm, test_id=test_id, dry_run=dry_run + ) if dry_run: return console.print("Build jobs deployed. Waiting for completion...\n") - success = _wait_for_jobs(ns, test_id, "build", BUILD_TIMEOUT_DEFAULT) + success = wait_for_completion( + build_backend, test_id, "build", BUILD_TIMEOUT_DEFAULT + ) if not success: console.print( @@ -837,16 +976,12 @@ def run( raise typer.Exit(1) console.print("\n[green]All builds completed successfully.[/green]\n") - # Clean build release before proceeding + # Remove the finished build workloads before starting the run phase. try: - helm_uninstall(build_release, ns) + build_backend.remove(test_id=test_id) except Exception: pass # Non-fatal - run_release = generate_release_name("run", run_hash) - run_values = generate_helm_values(config, "run", bm, test_id) - chart_path = get_chart_path() - detail = "" if bm == "tprocc": vu = config.hammerdb.tprocc.load_virtual_users @@ -863,28 +998,27 @@ def run( detail = f"SF {config.hammerdb.tproch.scale_factor}" print_banner(config.name, bm, target_count, detail) - console.print("Deploying jobs...") - result = helm_install(run_release, chart_path, ns, run_values, dry_run=dry_run) - + backend, names = _deploy( + config, ns, phase="run", benchmark=bm, test_id=test_id, dry_run=dry_run + ) if dry_run: - console.print(result.stdout) return - for i, host in enumerate(config.targets.hosts): - job_name = f"hdb-run-{i:02d}-{run_hash}" + for i, (name, host) in enumerate(zip(names, config.targets.hosts)): extra = "" if i == 0 and config.storage_metrics.enabled: extra = " [Pure Storage collector active]" - print_success(f"{job_name} ({host.name}){extra}") + print_success(f"{name} ({host.name}){extra}") - console.print(f"\n{target_count} benchmark jobs deployed to namespace '{ns}'.\n") + console.print(f"\n{target_count} benchmark jobs deployed via {backend.name}.\n") console.print( f"When complete: hammerdb-scale results --benchmark {bm} --id {test_id}" ) console.print(f"Generate report: hammerdb-scale report --id {test_id}") if wait: - _wait_for_jobs(ns, test_id, "load", timeout) + if not wait_for_completion(backend, test_id, "load", timeout): + raise typer.Exit(1) @app.command() @@ -900,18 +1034,12 @@ def status( import json as json_mod import time - from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_duration, - get_job_status, - get_job_target_host, - get_job_target_name, - resolve_test_id, - ) from hammerdb_scale.results.parsers import get_parser + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = _state.get("file") deploy_name = None + config = None try: config = load_config(discover_config_file(config_path)) ns = namespace or config.kubernetes.namespace @@ -919,34 +1047,35 @@ def status( except ConfigError: ns = namespace or DEFAULT_NAMESPACE - test_id = resolve_test_id(id, ns, deployment_name=deploy_name) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=deploy_name) + bm = ( + config.default_benchmark.value + if config is not None and config.default_benchmark + else "tprocc" + ) while True: - jobs = discover_jobs(ns, test_id) - if not jobs: - console.print(f"No jobs found for test '{test_id}' in namespace '{ns}'.") + workloads = backend.list_workloads(test_id) + if not workloads: + console.print(f"No jobs found for test '{test_id}'.") raise typer.Exit(1) - first_labels = jobs[0].get("metadata", {}).get("labels", {}) - phase = first_labels.get("hammerdb.io/phase", "unknown") - bm = first_labels.get("hammerdb.io/benchmark", "unknown") - if json_output: - data = [] - for job in jobs: - data.append( - { - "target": get_job_target_name(job), - "host": get_job_target_host(job), - "status": get_job_status(job), - "duration": get_job_duration(job), - } - ) + data = [ + { + "target": w.target_name, + "host": w.target_host, + "status": w.status, + "duration": w.duration_seconds, + } + for w in workloads + ] console.print(json_mod.dumps(data, indent=2)) return console.print(f"\nTest: {test_id}") - console.print(f"Phase: {phase} | Benchmark: {bm} | Namespace: {ns}\n") + console.print(f"Benchmark: {bm} | Backend: {backend.name}\n") table = Table() table.add_column("#", style="dim") @@ -964,12 +1093,11 @@ def status( failed = 0 running = 0 - for i, job in enumerate(jobs): - job_status = get_job_status(job) - duration = get_job_duration(job) - dur_str = _format_duration(duration) if duration else "-" - target_name = get_job_target_name(job) - target_host = get_job_target_host(job) + for i, w in enumerate(workloads): + job_status = w.status + dur_str = ( + _format_duration(w.duration_seconds) if w.duration_seconds else "-" + ) status_style = ( "green" @@ -985,18 +1113,9 @@ def status( if job_status == "Completed": completed += 1 - db_type = ( - job.get("metadata", {}) - .get("labels", {}) - .get("hammerdb.io/database-type", "oracle") - ) try: - parser = get_parser(db_type) - from hammerdb_scale.k8s.jobs import get_job_logs - - log_text = get_job_logs( - ns, job.get("metadata", {}).get("name", ""), tail=200 - ) + parser = get_parser(w.database_type) + log_text = backend.get_logs(w.name, tail=200) if bm == "tprocc": result = parser.parse_tprocc(log_text) if result: @@ -1015,8 +1134,8 @@ def status( row = [ str(i), - target_name, - target_host, + w.target_name, + w.target_host, f"[{status_style}]{job_status}[/{status_style}]", dur_str, ] @@ -1050,15 +1169,11 @@ def logs( tail: int = typer.Option(100, "--tail", help="Lines from end."), ) -> None: """Stream or fetch logs from benchmark jobs.""" - from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_logs, - get_job_target_name, - resolve_test_id, - ) + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = _state.get("file") deploy_name = None + config = None try: config = load_config(discover_config_file(config_path)) ns = namespace or config.kubernetes.namespace @@ -1066,33 +1181,27 @@ def logs( except ConfigError: ns = namespace or DEFAULT_NAMESPACE - test_id = resolve_test_id(id, ns, deployment_name=deploy_name) - jobs = discover_jobs(ns, test_id) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=deploy_name) + workloads = backend.list_workloads(test_id) - if not jobs: + if not workloads: console.print(f"No jobs found for test '{test_id}'.") raise typer.Exit(1) if target: - # Find job for specific target - matching = [j for j in jobs if get_job_target_name(j) == target] + matching = [w for w in workloads if w.target_name == target] if not matching: console.print(f"[red]No job found for target '{target}'.[/red]") raise typer.Exit(1) - job = matching[0] - job_name = job.get("metadata", {}).get("name", "") - log_text = get_job_logs(ns, job_name, tail=tail, follow=follow) - console.print(log_text) + console.print(backend.get_logs(matching[0].name, tail=tail)) else: # Show all logs, prefixed with target name colors = ["cyan", "green", "yellow", "magenta", "blue", "red"] - for i, job in enumerate(jobs): - target_name = get_job_target_name(job) - job_name = job.get("metadata", {}).get("name", "") + for i, w in enumerate(workloads): color = colors[i % len(colors)] - log_text = get_job_logs(ns, job_name, tail=tail) - for line in log_text.splitlines(): - console.print(f"[{color}]{target_name}[/{color}] {line}") + for line in backend.get_logs(w.name, tail=tail).splitlines(): + console.print(f"[{color}]{w.target_name}[/{color}] {line}") @app.command() @@ -1101,7 +1210,9 @@ def results( None, "--benchmark", help="tprocc or tproch." ), id: Optional[str] = typer.Option(None, "--id", help="Test ID."), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file." + ), namespace: Optional[str] = typer.Option( None, "-n", "--namespace", help="Namespace." ), @@ -1113,23 +1224,25 @@ def results( """Aggregate results from completed jobs.""" import json as json_mod - from hammerdb_scale.k8s.jobs import resolve_benchmark, resolve_test_id + from hammerdb_scale.k8s.jobs import resolve_benchmark from hammerdb_scale.results.aggregator import aggregate_results from hammerdb_scale.results.storage import load_results, save_results + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = file or _state.get("file") config = load_config(discover_config_file(config_path)) ns = namespace or config.kubernetes.namespace bm = resolve_benchmark(benchmark, config, "results") - test_id = resolve_test_id(id, ns, deployment_name=config.name) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=config.name) results_dir = output or Path("./results") - # Try aggregating from K8s first, fall back to local + # Try aggregating from the backend first, fall back to stored results try: summary, logs_dict, pure_metrics = aggregate_results( - config, ns, test_id, bm, results_dir + config, backend, test_id, bm, results_dir ) # Only save if K8s returned actual target data if summary.get("targets"): @@ -1144,12 +1257,24 @@ def results( raise typer.Exit(1) except typer.Exit: raise - except Exception: - # Fall back to local results + except Exception as e: + # The backend could not be queried (unreachable cluster, expired + # credentials, container runtime down). Stored results may still exist, + # so try those, but never let the real cause vanish: reporting + # "No results found" for an auth failure sends the user hunting in + # entirely the wrong place. summary = load_results(test_id, results_dir) if not summary: - console.print("[red]No results found.[/red]") + print_error(f"Could not read results from the backend: {e}") + console.print( + f"[red]No stored results for '{test_id}' either.[/red]\n" + f"If the backend is reachable, check that the test ID is correct." + ) raise typer.Exit(1) + print_warning( + f"Could not reach the backend ({type(e).__name__}); " + f"showing previously stored results." + ) if json_output: console.print(json_mod.dumps(summary, indent=2)) @@ -1176,17 +1301,19 @@ def report( None, "-o", "--output", help="Output HTML file." ), open_browser: bool = typer.Option(False, "--open", help="Open in default browser."), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file." + ), ) -> None: """Generate a self-contained HTML scorecard.""" import webbrowser - from hammerdb_scale.k8s.jobs import resolve_test_id from hammerdb_scale.results.storage import ( load_results, load_pure_metrics, results_exist, ) + from hammerdb_scale.runtime.resolve import resolve_test_id config_path = file or _state.get("file") config = None @@ -1198,7 +1325,8 @@ def report( except ConfigError: ns = DEFAULT_NAMESPACE - test_id = resolve_test_id(id, ns, deployment_name=deploy_name) + backend = _backend_for(config, ns) + test_id = resolve_test_id(backend, id, deployment_name=deploy_name) results_dir = Path("./results") # Auto-run results if not aggregated yet @@ -1217,7 +1345,7 @@ def report( config.default_benchmark.value if config.default_benchmark else "tprocc" ) summary, logs_dict, pure_metrics = aggregate_results( - config, ns, test_id, bm, results_dir + config, backend, test_id, bm, results_dir ) if summary.get("targets"): save_results(test_id, summary, logs_dict, pure_metrics, results_dir) @@ -1276,7 +1404,9 @@ def clean( benchmark: Optional[str] = typer.Option( None, "--benchmark", help="Required for --database." ), - file: Optional[Path] = typer.Option(None, "-f", "--file", help="Config file."), + file: Optional[Path] = typer.Option( + None, "-f", "-c", "--file", "--config", help="Config file." + ), target: Optional[str] = typer.Option( None, "--target", help="Clean specific target only." ), @@ -1320,20 +1450,32 @@ def clean( config_path = file or _state.get("file") if resources: + clean_config = None try: - config = load_config(discover_config_file(config_path)) - ns = namespace or config.kubernetes.namespace + clean_config = load_config(discover_config_file(config_path)) + ns = namespace or clean_config.kubernetes.namespace except ConfigError: ns = namespace or DEFAULT_NAMESPACE - from hammerdb_scale.clean.resources import clean_resources + backend = _backend_for(clean_config, ns) + if backend.name == "kubernetes": + from hammerdb_scale.clean.resources import clean_resources - clean_resources( - namespace=ns, - test_id=id, - everything=everything, - force=force, - ) + clean_resources( + namespace=ns, + test_id=id, + everything=everything, + force=force, + ) + else: + from hammerdb_scale.clean.resources import clean_container_resources + + clean_container_resources( + backend, + test_id=id, + everything=everything, + force=force, + ) if database: config = load_config(discover_config_file(config_path)) @@ -1349,64 +1491,82 @@ def clean( ) -def _format_duration(seconds: int | None) -> str: - """Format seconds as 'Xm Ys'.""" - if seconds is None: - return "-" - minutes = seconds // 60 - secs = seconds % 60 - if minutes > 0: - return f"{minutes}m {secs:02d}s" - return f"{secs}s" +def _backend_for(config: Optional[HammerDBScaleConfig], namespace: str): + """Get a backend, falling back to Kubernetes when no config was found. + Commands like `status` and `logs` can run without a config file, in which + case there is nothing to select a backend from. + """ + from hammerdb_scale.runtime import get_backend + from hammerdb_scale.runtime.kubernetes import KubernetesBackend -def _wait_for_jobs(namespace: str, test_id: str, phase: str, timeout: int) -> bool: - """Poll job status until all complete or timeout. Returns True if all succeeded.""" - import time + if config is None: + return KubernetesBackend(namespace=namespace) + return get_backend(config, namespace) - from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_status, - get_job_target_name, - ) - from hammerdb_scale.constants import POLL_INTERVAL - start = time.time() - while time.time() - start < timeout: - jobs = discover_jobs(namespace, test_id, phase=phase) - if not jobs: - time.sleep(POLL_INTERVAL) - continue +def _deploy( + config: HammerDBScaleConfig, + namespace: str, + *, + phase: str, + benchmark: str, + test_id: str, + dry_run: bool = False, +): + """Deploy one workload per target using the configured backend. + + Returns the backend and the workload names it created. + """ + from hammerdb_scale.config.defaults import expand_targets + from hammerdb_scale.runtime import get_backend + from hammerdb_scale.runtime.environment import build_target_env - statuses = [(get_job_target_name(j), get_job_status(j)) for j in jobs] - completed = sum(1 for _, s in statuses if s == "Completed") - failed = sum(1 for _, s in statuses if s == "Failed") - total = len(statuses) + backend = get_backend(config, namespace) - # Show progress - console.print( - f" [{completed}/{total}] completed, {failed} failed", - end="\r", - ) + problems = backend.preflight() + if problems: + for problem in problems: + print_error(problem) + raise typer.Exit(1) - if completed + failed >= total: - console.print() # New line after progress - if failed > 0: - for name, s in statuses: - if s == "Failed": - print_error(f"{name} Failed") - else: - print_success(f"{name} {s}") - return False - else: - for name, s in statuses: - print_success(f"{name} {s}") - return True + targets = expand_targets(config) + envs = [ + build_target_env( + config, + target, + phase=phase, + benchmark=benchmark, + test_id=test_id, + target_index=i, + ) + for i, target in enumerate(targets) + ] + + image = config.targets.defaults.image + console.print(f"Deploying jobs via {backend.name}...") + names = backend.deploy( + targets, + envs, + test_id=test_id, + phase=phase, + benchmark=benchmark, + image=f"{image.repository}:{image.tag}", + pull_policy=image.pull_policy.value, + dry_run=dry_run, + ) + return backend, names - time.sleep(POLL_INTERVAL) - console.print(f"\n[yellow]Timeout ({timeout}s) reached.[/yellow]") - return False +def _format_duration(seconds: int | None) -> str: + """Format seconds as 'Xm Ys'.""" + if seconds is None: + return "-" + minutes = seconds // 60 + secs = seconds % 60 + if minutes > 0: + return f"{minutes}m {secs:02d}s" + return f"{secs}s" def _display_results_table(summary: dict, benchmark: str) -> None: diff --git a/src/hammerdb_scale/config/defaults.py b/src/hammerdb_scale/config/defaults.py index ebe0b44..0d27b3a 100644 --- a/src/hammerdb_scale/config/defaults.py +++ b/src/hammerdb_scale/config/defaults.py @@ -70,6 +70,12 @@ def expand_targets(config: HammerDBScaleConfig) -> list[dict]: target["tproch"] = { "databaseName": mssql.get("tproch", {}).get("database_name", "tpch") } + elif effective_type == DatabaseType.postgres: + postgres = _deep_merge( + defaults.postgres.model_dump() if defaults.postgres else {}, + host.postgres.model_dump(exclude_none=True) if host.postgres else {}, + ) + target["postgres"] = postgres expanded.append(target) diff --git a/src/hammerdb_scale/config/schema.py b/src/hammerdb_scale/config/schema.py index c9bff17..e8857c5 100644 --- a/src/hammerdb_scale/config/schema.py +++ b/src/hammerdb_scale/config/schema.py @@ -12,6 +12,7 @@ class DatabaseType(str, Enum): oracle = "oracle" mssql = "mssql" + postgres = "postgres" class BenchmarkType(str, Enum): @@ -30,6 +31,21 @@ class ImagePullPolicy(str, Enum): never = "Never" +class BackendType(str, Enum): + """Where benchmark workloads execute.""" + + kubernetes = "kubernetes" + container = "container" # auto-detect podman or docker + podman = "podman" + docker = "docker" + + +class ContainerRuntimeName(str, Enum): + auto = "auto" + podman = "podman" + docker = "docker" + + # --- Oracle Config Models --- @@ -82,6 +98,39 @@ class MssqlConfig(BaseModel): connection: MssqlConnectionConfig = MssqlConnectionConfig() +# --- PostgreSQL Config Models --- + + +class PostgresTproccConfig(BaseModel): + database_name: str = "tpcc" + user: str = "tpcc" + password: str = "tpcc" + # HammerDB's recommended default for PostgreSQL TPROC-C, and generally the + # higher-throughput form. Must match between build and run: the driver + # calls procedures that only exist if the schema was built with them. + stored_procedures: bool = True + partition: bool = False + vacuum: bool = False + + +class PostgresTprochConfig(BaseModel): + database_name: str = "tpch" + user: str = "tpch" + password: str = "tpch" + # Degree of parallelism for the query phase. + max_parallel_workers: int = Field(default=8, ge=1) + + +class PostgresConfig(BaseModel): + port: int = Field(default=5432, ge=1, le=65535) + # PostgreSQL defaults to "prefer": TLS when the server offers it. + sslmode: str = "prefer" + # Empty means pg_default, which is the volume the data directory lives on. + tablespace: str = "" + tprocc: PostgresTproccConfig = PostgresTproccConfig() + tproch: PostgresTprochConfig = PostgresTprochConfig() + + # --- Image Config --- @@ -89,6 +138,10 @@ class ImageConfig(BaseModel): repository: str = "sillidata/hammerdb-scale" tag: str = "latest" pull_policy: ImagePullPolicy = ImagePullPolicy.always + # Where HammerDB lives inside the image. Only needs setting for a custom + # image built with a non-default HAMMERDB_VERSION; the container backend + # otherwise reads HAMMERDB_HOME from the image itself. + hammerdb_home: Optional[str] = None # --- Target Models --- @@ -103,6 +156,7 @@ class TargetHost(BaseModel): image: Optional[ImageConfig] = None oracle: Optional[OracleConfig] = None mssql: Optional[MssqlConfig] = None + postgres: Optional[PostgresConfig] = None class TargetDefaults(BaseModel): @@ -112,6 +166,7 @@ class TargetDefaults(BaseModel): image: ImageConfig = ImageConfig() oracle: Optional[OracleConfig] = None mssql: Optional[MssqlConfig] = None + postgres: Optional[PostgresConfig] = None class TargetsConfig(BaseModel): @@ -132,7 +187,12 @@ class TproccConfig(BaseModel): total_iterations: int = Field(default=10000000, ge=1) all_warehouses: bool = True checkpoint: bool = True - time_profile: bool = False + # On by default: this is what produces the per-transaction response time + # percentiles in the scorecard, and array-side latency is hard to argue + # from without the application's view of the same moment. Measured at + # under run-to-run variance on an 8-VU SQL Server run, so the data is + # effectively free. + time_profile: bool = True class TprochConfig(BaseModel): @@ -164,6 +224,20 @@ class ResourcesConfig(BaseModel): class KubernetesConfig(BaseModel): namespace: str = "hammerdb" job_ttl: int = Field(default=86400, ge=0) + # Emit a restricted-compliant securityContext on the Job pods. Needed on + # plain Kubernetes clusters enforcing the restricted Pod Security Standard. + # OpenShift injects an equivalent context via its SCC either way. + security_context: bool = True + # Pass database credentials through a Secret rather than plain env values + # in the Job spec, where `kubectl describe job` would expose them. + use_secrets: bool = True + + +class ContainerConfig(BaseModel): + """Settings for the local container backend (podman or docker).""" + + runtime: ContainerRuntimeName = ContainerRuntimeName.auto + network: Optional[str] = None # None uses the runtime's default network # --- Storage Metrics --- @@ -194,27 +268,33 @@ class HammerDBScaleConfig(BaseModel): name: str description: str = "" default_benchmark: Optional[BenchmarkType] = None + backend: BackendType = BackendType.kubernetes targets: TargetsConfig hammerdb: HammerDBConfig = HammerDBConfig() resources: ResourcesConfig = ResourcesConfig() kubernetes: KubernetesConfig = KubernetesConfig() + container: ContainerConfig = ContainerConfig() storage_metrics: StorageMetricsConfig = StorageMetricsConfig() @model_validator(mode="after") - def validate_database_type_config(self) -> "HammerDBScaleConfig": - """Ensure the active database type has its config block present.""" + def apply_database_type_defaults(self) -> "HammerDBScaleConfig": + """Fill in the database-specific block when it was not supplied. + + Every field in OracleConfig and MssqlConfig has a working default, so + requiring the block was pure boilerplate: it forced roughly 15 lines of + config that only restated the defaults. Omitting it now means "use the + defaults", which is what lets a minimal config be a dozen lines. + + Oracle is the exception worth noting: its schema passwords default to + empty and fall back to the target password at render time. + """ defaults = self.targets.defaults - db_type = defaults.type - if db_type == DatabaseType.oracle and defaults.oracle is None: - raise ValueError( - "targets.defaults.type is 'oracle' but targets.defaults.oracle " - "is not configured. Add an oracle: block under targets.defaults." - ) - if db_type == DatabaseType.mssql and defaults.mssql is None: - raise ValueError( - "targets.defaults.type is 'mssql' but targets.defaults.mssql " - "is not configured. Add an mssql: block under targets.defaults." - ) + if defaults.type == DatabaseType.oracle and defaults.oracle is None: + defaults.oracle = OracleConfig() + if defaults.type == DatabaseType.mssql and defaults.mssql is None: + defaults.mssql = MssqlConfig() + if defaults.type == DatabaseType.postgres and defaults.postgres is None: + defaults.postgres = PostgresConfig() return self @model_validator(mode="after") diff --git a/src/hammerdb_scale/constants.py b/src/hammerdb_scale/constants.py index b10922a..bd79d1c 100644 --- a/src/hammerdb_scale/constants.py +++ b/src/hammerdb_scale/constants.py @@ -2,7 +2,40 @@ from pathlib import Path -VERSION = "2.0.2" + +def _package_version() -> str: + """Resolve the CLI version, with pyproject.toml as the source of truth. + + Duplicating the version in this module drifted: it said 2.0.2 while + pyproject said 2.0.3, so `hammerdb-scale version` reported the wrong + number. Installed metadata alone is not enough either, because an editable + install keeps whatever version was current when `pip install -e` last ran + (observed reporting 2.0.0 against a 2.0.3 source tree). + + So: prefer pyproject.toml when running from a source checkout, and fall + back to installed metadata for a normal wheel install. + """ + pyproject = Path(__file__).resolve().parent.parent.parent / "pyproject.toml" + if pyproject.is_file(): + try: + import tomllib + + with open(pyproject, "rb") as fh: + declared = tomllib.load(fh).get("project", {}).get("version") + if declared: + return str(declared) + except (OSError, ValueError, ImportError): + pass + + from importlib.metadata import PackageNotFoundError, version + + try: + return version("hammerdb-scale") + except PackageNotFoundError: + return "0.0.0+unknown" + + +VERSION = _package_version() DEFAULT_CONFIG_FILENAMES = ["hammerdb-scale.yaml", "hammerdb-scale.yml"] CONFIG_ENV_VAR = "HAMMERDB_SCALE_CONFIG" @@ -11,6 +44,20 @@ # CLI phase -> Helm/entrypoint phase PHASE_MAP = {"build": "build", "run": "load"} +# The HammerDB version this repo builds images with. +DEFAULT_HAMMERDB_VERSION = "6.0" + +# Where the chart mounts TCL scripts by default. +# +# This deliberately tracks the *published* image rather than the version this +# repo builds, because the two can differ between a local build and a registry +# push. entrypoint.sh searches for its scripts rather than requiring an exact +# match, so a mismatch is tolerated either way; but pointing the default at a +# path the published image does not have would break every out-of-the-box +# Kubernetes run. Only move this after the matching images are pushed. +PUBLISHED_HAMMERDB_VERSION = "6.0" +DEFAULT_HAMMERDB_HOME = f"/opt/HammerDB-{PUBLISHED_HAMMERDB_VERSION}" + DEFAULT_RESULTS_DIR = "results" DEFAULT_JOB_TTL = 86400 NAMING_PREFIX = "hdb" @@ -64,6 +111,8 @@ def get_chart_path() -> str: DEFAULT_IMAGES = { "oracle": "sillidata/hammerdb-scale-oracle", "mssql": "sillidata/hammerdb-scale", + # The base image already carries libpq, so PostgreSQL needs no extension. + "postgres": "sillidata/hammerdb-scale", } diff --git a/src/hammerdb_scale/helm/values.py b/src/hammerdb_scale/helm/values.py index 92c22a8..21e3b04 100644 --- a/src/hammerdb_scale/helm/values.py +++ b/src/hammerdb_scale/helm/values.py @@ -4,7 +4,7 @@ from hammerdb_scale.config.defaults import expand_targets from hammerdb_scale.config.schema import HammerDBScaleConfig, MssqlConfig -from hammerdb_scale.constants import PHASE_MAP, VERSION +from hammerdb_scale.constants import DEFAULT_HAMMERDB_HOME, PHASE_MAP, VERSION from hammerdb_scale.k8s.naming import generate_run_hash @@ -42,6 +42,10 @@ def generate_helm_values( "tag": default_image.tag, "pullPolicy": default_image.pull_policy.value, }, + # Where the chart mounts the TCL scripts. entrypoint.sh searches + # for them, so this need not match the image's HAMMERDB_HOME. + "hammerdbHome": default_image.hammerdb_home or DEFAULT_HAMMERDB_HOME, + "securityContext": {"enabled": config.kubernetes.security_context}, "resources": { "requests": config.resources.requests.model_dump(), "limits": config.resources.limits.model_dump(), @@ -62,6 +66,7 @@ def generate_helm_values( }, "kubernetes": { "job_ttl": config.kubernetes.job_ttl, + "useSecrets": config.kubernetes.use_secrets, }, } diff --git a/src/hammerdb_scale/reports/generator.py b/src/hammerdb_scale/reports/generator.py index 1f7205b..9f21f84 100644 --- a/src/hammerdb_scale/reports/generator.py +++ b/src/hammerdb_scale/reports/generator.py @@ -7,6 +7,14 @@ from datetime import datetime +# Presentation names for the scorecard. The config carries the internal type. +DB_DISPLAY_NAMES = { + "oracle": "Oracle", + "mssql": "SQL Server", + "postgres": "PostgreSQL", +} + + def _load_chartjs() -> str: """Load the embedded Chart.js minified source.""" ref = importlib.resources.files("hammerdb_scale.reports").joinpath("chartjs.min.js") @@ -87,6 +95,8 @@ def generate_scorecard( tbody tr:nth-child(even) { background: var(--gray-50); } tbody tr:nth-child(odd) { background: var(--white); } .num { text-align: right; font-variant-numeric: tabular-nums; font-family: "SF Mono", "Cascadia Code", Consolas, monospace; } +.txn-name { font-weight: 600; letter-spacing: 0.02em; } +.worst { color: #b91c1c; } .status-ok { color: var(--green); font-weight: 600; } .status-fail { color: var(--red); font-weight: 600; } /* Charts */ @@ -163,7 +173,7 @@ def _header_html(summary: dict) -> str: meta_items = [ f"Test ID: {_escape(test_id)}", f"Benchmark: {benchmark.upper()}", - f"Database: {db_type}", + f"Database: {DB_DISPLAY_NAMES.get(db_type, db_type)}", f"Targets: {target_count}", ] if "warehouses" in cfg: @@ -329,6 +339,114 @@ def _storage_charts_js(pure_metrics: dict | None) -> str: return html +def _aggregate_timings(targets: list[dict]) -> list[dict]: + """Combine per-target transaction timings into one call-weighted view. + + Averages and percentiles are weighted by call count, because a target + that ran ten times the transactions should dominate the mean. Maximum is + the worst seen anywhere: a single stall on one target is exactly the + outlier a storage validation needs to surface, so it must not be averaged + away. + """ + combined: dict[str, dict] = {} + for target in targets: + for timing in (target.get("tprocc") or {}).get("timings", []): + name = timing.get("name") + if not name: + continue + calls = int(timing.get("calls", 0) or 0) + if calls <= 0: + continue + entry = combined.setdefault( + name, + { + "name": name, + "calls": 0, + "max_ms": 0.0, + "_weighted": { + k: 0.0 for k in ("avg_ms", "p50_ms", "p95_ms", "p99_ms") + }, + }, + ) + entry["calls"] += calls + entry["max_ms"] = max(entry["max_ms"], float(timing.get("max_ms", 0) or 0)) + for key in entry["_weighted"]: + entry["_weighted"][key] += float(timing.get(key, 0) or 0) * calls + + rows = [] + for entry in combined.values(): + calls = entry["calls"] + row = {"name": entry["name"], "calls": calls, "max_ms": entry["max_ms"]} + for key, total in entry["_weighted"].items(): + row[key] = total / calls if calls else 0.0 + rows.append(row) + rows.sort(key=lambda r: r["calls"], reverse=True) + return rows + + +def _latency_section_html(targets: list[dict]) -> str: + """Render the transaction response time section. + + Returns "" when the run had time profiling disabled, so the report simply + omits the section rather than showing an empty panel. + """ + rows = _aggregate_timings(targets) + if not rows: + return "" + + body = "\n".join( + f""" + {r["name"]} + {_fmt_number(r["calls"])} + {r["avg_ms"]:.2f} + {r["p50_ms"]:.2f} + {r["p95_ms"]:.2f} + {r["p99_ms"]:.2f} + {r["max_ms"]:,.2f} + """ + for r in rows + ) + + labels = json.dumps([r["name"] for r in rows]) + p50 = json.dumps([round(r["p50_ms"], 3) for r in rows]) + p95 = json.dumps([round(r["p95_ms"], 3) for r in rows]) + p99 = json.dumps([round(r["p99_ms"], 3) for r in rows]) + + return f"""

Transaction Response Times + + (milliseconds, call-weighted across targets) +

+ + + + + + + +{body} + +
TransactionCallsAvgp50p95p99Max
+
+""" + + def _storage_section_html(pure_metrics: dict | None) -> str: """Render the full Storage Performance section: cards, stats table, and charts.""" if not pure_metrics: @@ -562,6 +680,7 @@ def _render_tprocc(summary: dict, pure_metrics: dict | None) -> str: }}); """ + latency_section = _latency_section_html(targets) storage_section = _storage_section_html(pure_metrics) if has_storage else "" chartjs_src = _load_chartjs() @@ -582,6 +701,7 @@ def _render_tprocc(summary: dict, pure_metrics: dict | None) -> str: {table_html} {chart_html} +{latency_section} {storage_section} {_config_snapshot(summary)} diff --git a/src/hammerdb_scale/results/aggregator.py b/src/hammerdb_scale/results/aggregator.py index 65e68a7..a1c5b09 100644 --- a/src/hammerdb_scale/results/aggregator.py +++ b/src/hammerdb_scale/results/aggregator.py @@ -9,16 +9,7 @@ from hammerdb_scale.config.schema import HammerDBScaleConfig from hammerdb_scale.constants import VERSION -from hammerdb_scale.k8s.jobs import ( - discover_jobs, - get_job_database_type, - get_job_duration, - get_job_logs, - get_job_status, - get_job_target_host, - get_job_target_name, -) -from hammerdb_scale.results.parsers import get_parser +from hammerdb_scale.results.parsers import get_parser, parse_transaction_timings _PURE_JSON_START = ">>>PURE_METRICS_JSON_START<<<" @@ -137,28 +128,25 @@ def _extract(pattern: str, *keys: str) -> None: def aggregate_results( config: HammerDBScaleConfig, - namespace: str, + backend, test_id: str, benchmark: str, results_dir: Path = Path("./results"), ) -> dict: - """Aggregate results from all jobs for a test run.""" - # Determine phase to look for (run jobs have phase=load in Helm) - jobs = discover_jobs(namespace, test_id, phase="load") - if not jobs: - jobs = discover_jobs(namespace, test_id, phase="run") - if not jobs: - jobs = discover_jobs(namespace, test_id) - - # Sort jobs by target index - def _sort_key(j): - labels = j.get("metadata", {}).get("labels", {}) - try: - return int(labels.get("hammerdb.io/target-index", "99")) - except ValueError: - return 99 - - jobs.sort(key=_sort_key) + """Aggregate results from all workloads for a test run. + + ``backend`` is any object satisfying the Backend protocol, so this works + identically for Kubernetes Jobs and local containers. + """ + # The run phase is recorded as "load" by the entrypoint; fall back through + # the other spellings for older runs. + workloads = backend.list_workloads(test_id, phase="load") + if not workloads: + workloads = backend.list_workloads(test_id, phase="run") + if not workloads: + workloads = backend.list_workloads(test_id) + + workloads = sorted(workloads, key=lambda w: w.index) # Determine db type db_type = "oracle" @@ -170,17 +158,15 @@ def _sort_key(j): logs_dict: dict[str, str] = {} pure_metrics = None - for i, job in enumerate(jobs): - job_name = job.get("metadata", {}).get("name", "") - target_name = get_job_target_name(job) - target_host = get_job_target_host(job) - job_db_type = get_job_database_type(job) - if job_db_type != "unknown": - db_type = job_db_type + for i, w in enumerate(workloads): + target_name = w.target_name + target_host = w.target_host + if w.database_type != "unknown": + db_type = w.database_type - status = get_job_status(job) - duration = get_job_duration(job) - log_text = get_job_logs(namespace, job_name) + status = w.status + duration = w.duration_seconds + log_text = backend.get_logs(w.name) logs_dict[target_name] = log_text @@ -205,6 +191,24 @@ def _sort_key(j): "tpm": parsed.tpm, "nopm": parsed.nopm, } + # Present only when the run had time profiling on. Absent + # is normal, so downstream must treat it as optional. + timings = parse_transaction_timings(log_text) + if timings: + target_result["tprocc"]["timings"] = [ + { + "name": t.name, + "calls": t.calls, + "min_ms": t.min_ms, + "avg_ms": t.avg_ms, + "max_ms": t.max_ms, + "p50_ms": t.p50_ms, + "p95_ms": t.p95_ms, + "p99_ms": t.p99_ms, + "ratio_pct": t.ratio_pct, + } + for t in timings + ] elif benchmark == "tproch": parsed = parser.parse_tproch(log_text) if parsed: diff --git a/src/hammerdb_scale/results/parsers.py b/src/hammerdb_scale/results/parsers.py index a67c91d..bca22df 100644 --- a/src/hammerdb_scale/results/parsers.py +++ b/src/hammerdb_scale/results/parsers.py @@ -2,15 +2,110 @@ from __future__ import annotations +import json import re from abc import ABC, abstractmethod -from dataclasses import dataclass +from dataclasses import dataclass, field + + +@dataclass +class TransactionTiming: + """Response time distribution for one TPC-C transaction type. + + HammerDB 6.0 reports these from reservoir sampling, so the tail + percentiles are exact rather than bucket approximations. All times are + milliseconds as HammerDB emits them. + """ + + name: str + calls: int + min_ms: float + avg_ms: float + max_ms: float + p50_ms: float + p95_ms: float + p99_ms: float + ratio_pct: float @dataclass class TproccResult: tpm: int nopm: int + # Populated only when the run had time profiling enabled. Storage + # validation cares about this: array-side latency means little without + # the application's view of the same moment. + timings: list[TransactionTiming] = field(default_factory=list) + + +# The parse script prints this header immediately before the timing JSON. +_TIMING_HEADER = "TRANSACTION RESPONSE TIMES" + + +def _extract_json_object(text: str) -> str | None: + """Return the first balanced {...} block in text, or None.""" + start = text.find("{") + if start == -1: + return None + depth = 0 + for index in range(start, len(text)): + if text[index] == "{": + depth += 1 + elif text[index] == "}": + depth -= 1 + if depth == 0: + return text[start : index + 1] + return None + + +def parse_transaction_timings(log_text: str) -> list[TransactionTiming]: + """Pull per-transaction response times out of HammerDB 6.0 output. + + Returns an empty list when profiling was disabled, which is the common + case: HammerDB then emits a placeholder object saying the job has no + timing data, and callers must treat that exactly like absent data rather + than rendering an empty latency panel. + """ + header_at = log_text.find(_TIMING_HEADER) + if header_at == -1: + return [] + + block = _extract_json_object(log_text[header_at + len(_TIMING_HEADER) :]) + if not block: + return [] + try: + payload = json.loads(block) + except json.JSONDecodeError: + return [] + + timings: list[TransactionTiming] = [] + for name, entry in payload.items(): + if not isinstance(entry, dict) or "calls" not in entry: + # The "no timing data" placeholder, or a job-id wrapper. + continue + + def _number(key: str) -> float: + try: + return float(entry.get(key, 0) or 0) + except (TypeError, ValueError): + return 0.0 + + timings.append( + TransactionTiming( + name=name, + calls=int(_number("calls")), + min_ms=_number("min_ms"), + avg_ms=_number("avg_ms"), + max_ms=_number("max_ms"), + p50_ms=_number("p50_ms"), + p95_ms=_number("p95_ms"), + p99_ms=_number("p99_ms"), + ratio_pct=_number("ratio_pct"), + ) + ) + # Busiest transaction first: that is the one a reader wants to see. + timings.sort(key=lambda t: t.calls, reverse=True) + return timings @dataclass @@ -142,6 +237,57 @@ def detect_error(self, log_text: str) -> str | None: return _detect_generic_error(log_text) +class PostgresParser(OutputParser): + # HammerDB reports "System achieved N NOPM from M PostgreSQL TPM". + TPM_PATTERN = re.compile(r"(\d+)\s+(?:PostgreSQL\s+)?TPM") + NOPM_PATTERN = re.compile(r"(\d+)\s+(?:PostgreSQL\s+)?NOPM") + SYSTEM_TPM_PATTERN = re.compile( + r"System achieved\s+(\d+)\s+(?:PostgreSQL\s+)?NOPM\s+from\s+" + r"(\d+)\s+(?:PostgreSQL\s+)?TPM" + ) + + QPHH_PATTERN = re.compile(r"QphH(?:@\d+)?[:\s]+([0-9]+\.?[0-9]*)") + QUERY_PATTERN = re.compile(r"Query\s+(\d+)[:\s]+([0-9]+\.?[0-9]*)\s+seconds?") + + def parse_tprocc(self, log_text: str) -> TproccResult | None: + sys_matches = self.SYSTEM_TPM_PATTERN.findall(log_text) + if sys_matches: + nopm, tpm = sys_matches[-1] + return TproccResult(tpm=int(tpm), nopm=int(nopm)) + + tpm_matches = self.TPM_PATTERN.findall(log_text) + nopm_matches = self.NOPM_PATTERN.findall(log_text) + if tpm_matches and nopm_matches: + return TproccResult( + tpm=int(tpm_matches[-1]), + nopm=int(nopm_matches[-1]), + ) + return None + + def parse_tproch(self, log_text: str) -> TprochResult | None: + qphh_matches = self.QPHH_PATTERN.findall(log_text) + if not qphh_matches: + return None + + qphh = float(qphh_matches[-1]) + seen: dict[int, float] = {} + for qnum, qtime in self.QUERY_PATTERN.findall(log_text): + seen[int(qnum)] = float(qtime) + queries = [ + TprochQueryResult(query_number=qn, time_seconds=seen[qn]) + for qn in sorted(seen) + ] + return TprochResult(qphh=qphh, queries=queries) + + def detect_error(self, log_text: str) -> str | None: + # PostgreSQL errors carry a five-character SQLSTATE, e.g. + # "ERROR: relation "warehouse" does not exist". + match = re.search(r"(?:FATAL|PANIC|ERROR):\s+.*?(?:\n|$)", log_text) + if match: + return match.group(0).strip() + return _detect_generic_error(log_text) + + def _detect_generic_error(log_text: str) -> str | None: """Generic error detection shared across parsers.""" if "Error" in log_text or "FATAL" in log_text: @@ -154,6 +300,7 @@ def _detect_generic_error(log_text: str) -> str | None: PARSERS: dict[str, OutputParser] = { "oracle": OracleParser(), "mssql": MssqlParser(), + "postgres": PostgresParser(), } diff --git a/src/hammerdb_scale/runtime/__init__.py b/src/hammerdb_scale/runtime/__init__.py new file mode 100644 index 0000000..d7edcff --- /dev/null +++ b/src/hammerdb_scale/runtime/__init__.py @@ -0,0 +1,62 @@ +"""Execution backends for HammerDB workloads. + +``get_backend()`` is the single entry point the CLI uses to obtain a backend. +""" + +from __future__ import annotations + +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_PENDING, + STATUS_RUNNING, + Backend, + WorkloadRef, +) + +__all__ = [ + "Backend", + "WorkloadRef", + "STATUS_PENDING", + "STATUS_RUNNING", + "STATUS_COMPLETED", + "STATUS_FAILED", + "get_backend", +] + + +def get_backend(config, namespace: str | None = None) -> Backend: + """Build the backend selected by the config. + + Kubernetes remains the default so that existing configs behave exactly as + they did before backends were introduced. + """ + backend_name = getattr(config, "backend", "kubernetes") + backend_name = getattr(backend_name, "value", backend_name) + + if backend_name in ("container", "docker", "podman"): + from hammerdb_scale.runtime.container import ContainerBackend + + container_cfg = getattr(config, "container", None) + runtime = getattr(container_cfg, "runtime", None) + runtime = getattr(runtime, "value", runtime) + if runtime == "auto": + runtime = None + # An explicit `backend: docker` or `backend: podman` names the runtime. + if backend_name in ("docker", "podman"): + runtime = backend_name + + limits = config.resources.limits + return ContainerBackend( + runtime=runtime, + network=getattr(container_cfg, "network", None), + hammerdb_home=config.targets.defaults.image.hammerdb_home, + memory_limit=limits.memory, + cpu_limit=limits.cpu, + deployment_name=config.name, + ) + + from hammerdb_scale.runtime.kubernetes import KubernetesBackend + + ns = namespace or config.kubernetes.namespace + return KubernetesBackend(namespace=ns, config=config) diff --git a/src/hammerdb_scale/runtime/base.py b/src/hammerdb_scale/runtime/base.py new file mode 100644 index 0000000..e78fafb --- /dev/null +++ b/src/hammerdb_scale/runtime/base.py @@ -0,0 +1,77 @@ +"""Backend protocol shared by the Kubernetes and container execution paths. + +A backend is responsible for turning a set of expanded targets into running +HammerDB workloads and for reporting on them afterwards. Everything above this +layer (config parsing, result parsing, aggregation, reporting) is transport +agnostic and must not import backend-specific modules. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Protocol, runtime_checkable + + +# Job lifecycle states, normalised across backends. The Kubernetes backend maps +# Job conditions onto these; the container backend maps exit codes. +STATUS_PENDING = "Pending" +STATUS_RUNNING = "Running" +STATUS_COMPLETED = "Completed" +STATUS_FAILED = "Failed" + + +@dataclass +class WorkloadRef: + """A single running or completed HammerDB workload. + + This is the backend-neutral view of what Kubernetes calls a Job and what + the container backend calls a container. + """ + + name: str + target_name: str + target_host: str + database_type: str + index: int + status: str + duration_seconds: int | None = None + + +@runtime_checkable +class Backend(Protocol): + """Execution backend for HammerDB workloads.""" + + name: str + + def deploy( + self, + targets: list[dict], + env_per_target: list[dict[str, str]], + *, + test_id: str, + phase: str, + benchmark: str, + image: str, + pull_policy: str, + dry_run: bool = False, + ) -> list[str]: + """Start one workload per target. Returns the workload names created.""" + ... + + def list_workloads( + self, test_id: str, phase: str | None = None + ) -> list[WorkloadRef]: + """Find all workloads belonging to a test run, newest phase first.""" + ... + + def get_logs(self, workload_name: str, tail: int | None = None) -> str: + """Fetch the full stdout/stderr of a workload.""" + ... + + def remove(self, test_id: str | None = None, everything: bool = False) -> int: + """Remove workloads for a test run. Returns the count removed.""" + ... + + def preflight(self) -> list[str]: + """Check that this backend can run. Returns a list of problems, empty if OK.""" + ... diff --git a/src/hammerdb_scale/runtime/container.py b/src/hammerdb_scale/runtime/container.py new file mode 100644 index 0000000..656d4f9 --- /dev/null +++ b/src/hammerdb_scale/runtime/container.py @@ -0,0 +1,700 @@ +"""Container execution backend for podman and docker. + +Runs one HammerDB container per database target on the local host, with no +Kubernetes involved. This is the low-friction path: a user needs a container +runtime and a config file rather than a cluster, a kubeconfig, helm, kubectl +and RBAC to create Jobs. + +podman and docker expose a compatible CLI for everything used here, so a single +implementation drives both. podman is preferred when present because it is +rootless and daemonless. + +Labels carry the same metadata the Kubernetes backend puts in Job labels, which +is what lets test-id resolution, status and log retrieval behave identically on +either backend. +""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import tempfile +from datetime import datetime, timezone +from pathlib import Path + +from hammerdb_scale.constants import ( + DEFAULT_HAMMERDB_HOME, + PHASE_MAP, + HammerDBScaleError, + get_chart_path, +) +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_RUNNING, + WorkloadRef, +) + +# Label keys, mirroring the Kubernetes label scheme. +LABEL_MANAGED = "hammerdb.io/managed-by" +LABEL_TEST_ID = "hammerdb.io/test-id" +LABEL_PHASE = "hammerdb.io/phase" +LABEL_TARGET = "hammerdb.io/target-name" +LABEL_TARGET_HOST = "hammerdb.io/target-host" +LABEL_DB_TYPE = "hammerdb.io/database-type" +LABEL_INDEX = "hammerdb.io/target-index" +# Mirrors the Kubernetes label of the same name. Without it, every run on a +# host shares one namespace, so `results` after one config's run can pick up a +# different config's containers. +LABEL_DEPLOYMENT = "hammerdb.io/deployment-name" + +MANAGED_VALUE = "hammerdb-scale" + +# Kubernetes memory suffixes. The binary ones are what resources.limits.memory +# normally uses; the decimal ones are accepted by Kubernetes so we accept them +# too rather than silently mis-sizing a container. +_MEMORY_SUFFIXES = { + "Ki": 1024, + "Mi": 1024**2, + "Gi": 1024**3, + "Ti": 1024**4, + "K": 1000, + "M": 1000**2, + "G": 1000**3, + "T": 1000**4, +} + + +class ContainerRuntimeError(HammerDBScaleError): + """The container runtime is missing or a command against it failed.""" + + +def k8s_memory_to_bytes(value: str) -> int | None: + """Convert a Kubernetes memory quantity to bytes. + + The `resources` block is shared by both backends and is written in + Kubernetes units, so the container path has to translate rather than + ignore it. Returns None when the value cannot be understood, so an odd + quantity degrades to "no limit" rather than aborting the run. + """ + text = str(value).strip() + if not text: + return None + for suffix, factor in sorted(_MEMORY_SUFFIXES.items(), key=lambda kv: -len(kv[0])): + if text.endswith(suffix): + try: + return int(float(text[: -len(suffix)]) * factor) + except ValueError: + return None + try: + return int(float(text)) + except ValueError: + return None + + +def k8s_cpu_to_cores(value: str) -> float | None: + """Convert a Kubernetes CPU quantity to a core count. + + Accepts both plain cores ("8") and millicores ("500m"). + """ + text = str(value).strip() + if not text: + return None + try: + if text.endswith("m"): + return float(text[:-1]) / 1000.0 + return float(text) + except ValueError: + return None + + +def selinux_is_enforcing() -> bool: + """True when the host runs SELinux and would block an unlabelled mount. + + Only such hosts need the :z relabel suffix. Docker Desktop's VM has + historically rejected or mishandled it, so applying it unconditionally + turns the first run on a Mac into a mount error. + """ + try: + enforce = Path("/sys/fs/selinux/enforce") + return enforce.exists() and enforce.read_text().strip() == "1" + except OSError: + return False + + +def mount_suffix() -> str: + """Volume options for the read-only script mount.""" + return "ro,z" if selinux_is_enforcing() else "ro" + + +def detect_runtime(preferred: str | None = None) -> str: + """Find an available container runtime. + + Prefers podman (rootless, daemonless) over docker unless told otherwise. + """ + candidates = [preferred] if preferred else ["podman", "docker"] + for name in candidates: + if name and shutil.which(name): + return name + raise ContainerRuntimeError( + "No container runtime found. Install podman " + "(https://podman.io/getting-started/installation) or docker " + "(https://docs.docker.com/get-docker/), or set backend: kubernetes." + ) + + +class ContainerBackend: + """Runs HammerDB workloads as local containers.""" + + def __init__( + self, + runtime: str | None = None, + network: str | None = None, + scripts_dir: Path | None = None, + hammerdb_home: str | None = None, + memory_limit: str | None = None, + cpu_limit: str | None = None, + deployment_name: str | None = None, + ) -> None: + self.runtime = detect_runtime(runtime) + self.name = self.runtime + self.network = network + self._scripts_dir = scripts_dir + self.hammerdb_home = hammerdb_home + self.memory_limit = memory_limit + self.cpu_limit = cpu_limit + self.deployment_name = deployment_name + + def _scope_filters(self) -> list[str]: + """ps filters restricting results to this config's containers. + + Containers deployed before deployment-name labelling carry no such + label, so scoping is applied only when a name is known; that keeps a + mixed host readable rather than hiding older runs entirely. + """ + filters = ["--filter", f"label={LABEL_MANAGED}={MANAGED_VALUE}"] + if self.deployment_name: + filters.extend( + ["--filter", f"label={LABEL_DEPLOYMENT}={self.deployment_name}"] + ) + return filters + + def _resource_args(self) -> list[str]: + """Translate the shared `resources.limits` block for this runtime. + + Both podman and docker accept --memory/--cpus, so a limit set in the + config must be applied here too. Previously the block was read only by + the Helm path, so a container run silently ignored it and the user + believed the driver was capped when it was not. + """ + args: list[str] = [] + if self.memory_limit: + as_bytes = k8s_memory_to_bytes(self.memory_limit) + if as_bytes: + args.extend(["--memory", str(as_bytes)]) + if self.cpu_limit: + cores = k8s_cpu_to_cores(self.cpu_limit) + if cores: + args.extend(["--cpus", f"{cores:g}"]) + return args + + # --- internals --- + + def _run( + self, args: list[str], timeout: int = 300, check: bool = True + ) -> subprocess.CompletedProcess: + """Invoke the container runtime CLI.""" + result = subprocess.run( + [self.runtime] + args, + capture_output=True, + text=True, + timeout=timeout, + encoding="utf-8", + errors="replace", + ) + if check and result.returncode != 0: + raise ContainerRuntimeError( + f"{self.runtime} {' '.join(args[:3])} failed:\n{result.stderr.strip()}" + ) + return result + + def _scripts_path(self, database_type: str) -> Path: + """Locate the TCL scripts for a database type. + + These are the same files the Helm chart packages into a ConfigMap, so + both backends execute identical benchmark logic. + """ + base = self._scripts_dir or (Path(get_chart_path()) / "scripts") + path = base / database_type + if not path.is_dir(): + raise ContainerRuntimeError( + f"No TCL scripts found for database type '{database_type}' at {path}" + ) + return path + + @staticmethod + def _normalise_phase(phase: str) -> str: + """Map the CLI phase onto the label the Kubernetes path records. + + The CLI says "run" but Helm and the entrypoint both record "load". + Containers must use the same vocabulary or callers that filter by phase + silently find nothing. + """ + return PHASE_MAP.get(phase, phase) + + @classmethod + def _container_name(cls, phase: str, index: int, test_id: str) -> str: + return f"hdb-{cls._normalise_phase(phase)}-{index:02d}-{test_id}" + + # --- Backend protocol --- + + def preflight(self) -> list[str]: + """Verify the runtime responds before we try to deploy against it.""" + problems: list[str] = [] + try: + result = self._run( + ["version", "--format", "{{.Client.Version}}"], timeout=30, check=False + ) + if result.returncode != 0: + # Older podman/docker may not support that format string. + result = self._run(["--version"], timeout=30, check=False) + if result.returncode != 0: + problems.append( + f"{self.runtime} is installed but not responding: " + f"{result.stderr.strip()}" + ) + except Exception as e: + problems.append(f"Could not run {self.runtime}: {e}") + return problems + + def deploy( + self, + targets: list[dict], + env_per_target: list[dict[str, str]], + *, + test_id: str, + phase: str, + benchmark: str, + image: str, + pull_policy: str = "Always", + dry_run: bool = False, + ) -> list[str]: + """Start one detached container per target.""" + if pull_policy == "Always" and not dry_run: + self._run(["pull", image], timeout=1800, check=False) + + label_phase = self._normalise_phase(phase) + names: list[str] = [] + for index, (target, env) in enumerate(zip(targets, env_per_target)): + name = self._container_name(phase, index, test_id) + scripts = self._scripts_path(target["type"]) + + args = [ + "run", + "--detach", + "--name", + name, + "--label", + f"{LABEL_MANAGED}={MANAGED_VALUE}", + "--label", + f"{LABEL_TEST_ID}={test_id}", + "--label", + f"{LABEL_PHASE}={label_phase}", + "--label", + f"{LABEL_TARGET}={target['name']}", + "--label", + f"{LABEL_TARGET_HOST}={target['host']}", + "--label", + f"{LABEL_DB_TYPE}={target['type']}", + "--label", + f"{LABEL_INDEX}={index}", + ] + + if self.deployment_name: + args.extend(["--label", f"{LABEL_DEPLOYMENT}={self.deployment_name}"]) + + if self.network: + args.extend(["--network", self.network]) + + args.extend(self._resource_args()) + + # Env vars go via a file so that passwords never appear in the + # process list or the shell history of the calling user. + env_file = tempfile.NamedTemporaryFile( + mode="w", suffix=".env", delete=False, encoding="utf-8" + ) + try: + for key, value in env.items(): + env_file.write(f"{key}={value}\n") + env_file.close() + args.extend(["--env-file", env_file.name]) + + # Mount the TCL scripts where entrypoint.sh expects them. The + # The :z relabel is applied only where SELinux needs it; see + # mount_suffix(). + mount_target = self._script_mount_target(image) + args.extend(["-v", f"{scripts}:{mount_target}:{mount_suffix()}"]) + args.append(image) + + if dry_run: + redacted = [ + a if not a.startswith("/tmp/") else "" for a in args + ] + print(f"{self.runtime} " + " ".join(redacted)) + names.append(name) + continue + + self._run(args, timeout=120) + names.append(name) + finally: + if not dry_run: + Path(env_file.name).unlink(missing_ok=True) + + return names + + def _script_mount_target(self, image: str) -> str: + """Where inside the container the TCL scripts must appear. + + Preference order: an explicit config override, then the image's own + HAMMERDB_HOME, then a probe for an installed HammerDB. Getting this + wrong means entrypoint.sh cannot find its scripts, so the probe is + worth the extra call. + """ + if self.hammerdb_home: + return f"{self.hammerdb_home}/scripts" + + result = self._run( + [ + "image", + "inspect", + image, + "--format", + "{{range .Config.Env}}{{println .}}{{end}}", + ], + timeout=60, + check=False, + ) + if result.returncode == 0: + for line in result.stdout.splitlines(): + if line.startswith("HAMMERDB_HOME="): + home = line.split("=", 1)[1].strip() + if home: + return f"{home}/scripts" + + # Older images predate HAMMERDB_HOME. Ask the image where HammerDB is + # rather than guessing a version that may not match. + probe = self._run( + [ + "run", + "--rm", + "--entrypoint", + "/bin/sh", + image, + "-c", + "ls -d /opt/HammerDB-* 2>/dev/null | head -1", + ], + timeout=120, + check=False, + ) + discovered = probe.stdout.strip().splitlines() + if probe.returncode == 0 and discovered and discovered[0].startswith("/opt/"): + return f"{discovered[0].strip()}/scripts" + + return f"{DEFAULT_HAMMERDB_HOME}/scripts" + + def list_workloads( + self, test_id: str, phase: str | None = None + ) -> list[WorkloadRef]: + """Find containers for a test run.""" + filters = [ + "--filter", + f"label={LABEL_MANAGED}={MANAGED_VALUE}", + "--filter", + f"label={LABEL_TEST_ID}={test_id}", + ] + if phase: + filters.extend( + ["--filter", f"label={LABEL_PHASE}={self._normalise_phase(phase)}"] + ) + + result = self._run(["ps", "--all", "--format", "json"] + filters, check=False) + if result.returncode != 0 or not result.stdout.strip(): + return [] + + entries = self._parse_ps_json(result.stdout) + workloads = [self._to_workload(e) for e in entries] + return sorted(workloads, key=lambda w: w.index) + + @staticmethod + def _parse_ps_json(stdout: str) -> list[dict]: + """Parse `ps --format json`. + + podman emits a JSON array; docker emits newline-delimited objects. + """ + text = stdout.strip() + try: + data = json.loads(text) + return data if isinstance(data, list) else [data] + except json.JSONDecodeError: + pass + + entries = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + try: + entries.append(json.loads(line)) + except json.JSONDecodeError: + continue + return entries + + def _to_workload(self, entry: dict) -> WorkloadRef: + """Normalise a runtime-specific ps entry into a WorkloadRef.""" + labels = entry.get("Labels") or {} + if isinstance(labels, str): + labels = dict( + pair.split("=", 1) for pair in labels.split(",") if "=" in pair + ) + + name = entry.get("Names") or entry.get("Name") or "" + if isinstance(name, list): + name = name[0] if name else "" + + try: + index = int(labels.get(LABEL_INDEX, 0)) + except (TypeError, ValueError): + index = 0 + + return WorkloadRef( + name=name, + target_name=labels.get(LABEL_TARGET, "unknown"), + target_host=labels.get(LABEL_TARGET_HOST, "unknown"), + database_type=labels.get(LABEL_DB_TYPE, "unknown"), + index=index, + status=self._normalise_status(entry), + duration_seconds=self._duration(name), + ) + + @staticmethod + def _normalise_status(entry: dict) -> str: + """Map runtime state onto the shared status vocabulary.""" + state = str(entry.get("State", "")).lower() + exit_code = entry.get("ExitCode") + + if state in ("running", "up"): + return STATUS_RUNNING + if state in ("created", "paused"): + return STATUS_RUNNING + if state in ("exited", "stopped", "dead"): + # docker sometimes reports status as "Exited (1) 2 minutes ago" + if exit_code is None: + status_text = str(entry.get("Status", "")) + exit_code = 0 if "(0)" in status_text else 1 + return STATUS_COMPLETED if int(exit_code) == 0 else STATUS_FAILED + return STATUS_RUNNING + + def _duration(self, container_name: str) -> int | None: + """Wall-clock runtime of a container, in seconds.""" + if not container_name: + return None + # Ask for RFC3339 explicitly. podman's default rendering of these + # fields is Go's native time format ("2026-07-24 23:32:08.69 -0600 MDT"), + # which is not parseable as ISO 8601. + result = self._run( + [ + "inspect", + container_name, + "--format", + "{{.State.StartedAt.Format " + '"2006-01-02T15:04:05.999999999Z07:00"' + "}}|{{.State.FinishedAt.Format " + '"2006-01-02T15:04:05.999999999Z07:00"' + "}}", + ], + timeout=30, + check=False, + ) + if result.returncode != 0: + # docker exposes these as pre-formatted strings, so .Format fails. + result = self._run( + [ + "inspect", + container_name, + "--format", + "{{.State.StartedAt}}|{{.State.FinishedAt}}", + ], + timeout=30, + check=False, + ) + if result.returncode != 0: + return None + + raw = result.stdout.strip() + if "|" not in raw: + return None + start_s, end_s = raw.split("|", 1) + start = _parse_ts(start_s) + end = _parse_ts(end_s) + if not start: + return None + if not end: + end = datetime.now(timezone.utc) + seconds = int((end - start).total_seconds()) + return seconds if seconds >= 0 else None + + def get_logs(self, workload_name: str, tail: int | None = None) -> str: + """Fetch container logs.""" + args = ["logs"] + if tail is not None: + args.extend(["--tail", str(tail)]) + args.append(workload_name) + result = self._run(args, timeout=120, check=False) + # HammerDB writes to both streams; callers want the combined output. + return (result.stdout or "") + (result.stderr or "") + + def remove(self, test_id: str | None = None, everything: bool = False) -> int: + """Remove containers for a test run, or every managed container. + + "Everything" means everything belonging to this config, not every + hammerdb-scale container on the host: cleaning up one validation must + not delete a colleague's run. + """ + filters = self._scope_filters() + if test_id and not everything: + filters.extend(["--filter", f"label={LABEL_TEST_ID}={test_id}"]) + + result = self._run(["ps", "--all", "--quiet"] + filters, check=False) + ids = [line.strip() for line in result.stdout.splitlines() if line.strip()] + if not ids: + return 0 + + self._run(["rm", "--force"] + ids, timeout=180, check=False) + return len(ids) + + def wait(self, workload_names: list[str], timeout: int) -> None: + """Block until the given containers exit, or the timeout elapses.""" + if not workload_names: + return + self._run(["wait"] + workload_names, timeout=timeout, check=False) + + def find_test_ids(self) -> list[str]: + """List test IDs that have managed containers, most recent first. + + Ordering matters: `results` and `report` act on the first entry, so a + stale build run appearing ahead of the run the user just finished sends + them to the wrong data. The runtime does not guarantee ps ordering, so + sort explicitly by container creation time. + + Scoped to this config where possible. Containers created before + deployment-name labelling carry no such label, so an empty scoped + result falls back to the unscoped query rather than telling the user + their completed run does not exist. + """ + result = self._run( + ["ps", "--all"] + self._scope_filters() + ["--format", "json"], + check=False, + ) + entries = self._parse_ps_json(result.stdout) if result.returncode == 0 else [] + if not entries and self.deployment_name: + result = self._run( + [ + "ps", + "--all", + "--filter", + f"label={LABEL_MANAGED}={MANAGED_VALUE}", + "--format", + "json", + ], + check=False, + ) + entries = ( + self._parse_ps_json(result.stdout) if result.returncode == 0 else [] + ) + if not entries: + return [] + + newest: dict[str, float] = {} + for entry in entries: + labels = entry.get("Labels") or {} + if isinstance(labels, str): + labels = dict( + pair.split("=", 1) for pair in labels.split(",") if "=" in pair + ) + test_id = labels.get(LABEL_TEST_ID) + if not test_id: + continue + created = _created_sort_key(entry) + if created > newest.get(test_id, float("-inf")): + newest[test_id] = created + + return sorted(newest, key=lambda t: newest[t], reverse=True) + + +def _created_sort_key(entry: dict) -> float: + """Creation time of a ps entry as a sortable number. + + podman reports `Created` as a unix timestamp, docker as an RFC3339 string. + Unparseable entries sort oldest so they never displace a known-good one. + """ + created = entry.get("Created") or entry.get("CreatedAt") + if isinstance(created, (int, float)): + return float(created) + if isinstance(created, str): + parsed = _parse_ts(created) + if parsed: + return parsed.timestamp() + try: + return float(created) + except ValueError: + return float("-inf") + return float("-inf") + + +def _parse_ts(value: str) -> datetime | None: + """Parse a container runtime timestamp. + + Both runtimes emit RFC3339 with varying sub-second precision, and use a + zero value for containers that have not finished. + """ + value = value.strip() + if not value or value.startswith("0001-01-01"): + return None + + # Go's native time rendering, e.g. "2026-07-24 23:32:08.6967 -0600 MDT". + # Drop the trailing timezone abbreviation and keep the numeric offset, + # inserting the colon fromisoformat requires on Python < 3.11. + parts = value.split() + if ( + len(parts) >= 3 + and ":" in parts[1] + and (parts[2].startswith("+") or parts[2].startswith("-")) + ): + offset = parts[2] + if len(offset) == 5 and offset[1:].isdigit(): + offset = f"{offset[:3]}:{offset[3:]}" + value = f"{parts[0]}T{parts[1]}{offset}" + + text = value.replace("Z", "+00:00") + # Trim sub-second precision beyond microseconds, which fromisoformat rejects + # on older Python versions. + if "." in text: + head, _, tail = text.partition(".") + digits = "" + rest = "" + for i, ch in enumerate(tail): + if ch.isdigit(): + digits += ch + else: + rest = tail[i:] + break + text = f"{head}.{digits[:6]}{rest}" + + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + if parsed.tzinfo is None: + parsed = parsed.replace(tzinfo=timezone.utc) + return parsed diff --git a/src/hammerdb_scale/runtime/environment.py b/src/hammerdb_scale/runtime/environment.py new file mode 100644 index 0000000..949bdb7 --- /dev/null +++ b/src/hammerdb_scale/runtime/environment.py @@ -0,0 +1,254 @@ +"""Build the HammerDB container environment from a v2 config. + +This module is the single source of truth for the container's environment +variable contract. Both the Kubernetes backend (via the Helm chart) and the +container backend (via ``podman``/``docker run -e``) must produce the same +variables for the same config, otherwise the two backends silently diverge. + +The contract itself is defined by ``entrypoint.sh`` and the TCL scripts it +dispatches to. Previously it was expressed only in Go template syntax inside +``templates/job-hammerdb-worker.yaml``; lifting it here lets a non-Kubernetes +backend reuse it and lets it be unit tested. +""" + +from __future__ import annotations + +from hammerdb_scale.config.schema import HammerDBScaleConfig, MssqlConfig +from hammerdb_scale.constants import PHASE_MAP + +# HammerDB driver name per database type, mirroring the +# "hammerdb-scale-test.dbDriver" helper in _helpers.tpl. +DB_DRIVERS = { + "mssql": "mssqls", + "oracle": "oracle", + "postgres": "pg", + "mysql": "mysql", +} + + +def get_db_driver(database_type: str) -> str: + """Return the HammerDB driver name for a database type.""" + driver = DB_DRIVERS.get(database_type) + if not driver: + raise ValueError(f"Unsupported database type: {database_type}") + return driver + + +def _bool_str(value: object) -> str: + """Render a value the way the Helm template's `quote` does for booleans.""" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def build_target_env( + config: HammerDBScaleConfig, + target: dict, + *, + phase: str, + benchmark: str, + test_id: str, + target_index: int, +) -> dict[str, str]: + """Build the full environment for one target's HammerDB container. + + Args: + config: The validated v2 configuration. + target: One expanded target dict from ``expand_targets()``. + phase: CLI phase, ``"build"`` or ``"run"``. + benchmark: ``"tprocc"`` or ``"tproch"``. + test_id: The test run identifier. + target_index: Zero-based index of this target within the run. + + Returns: + A flat ``{name: value}`` mapping with every value already a string, + matching what the Kubernetes Job spec would set. + """ + db_type = target["type"] + run_mode = PHASE_MAP.get(phase, phase) + + env: dict[str, str] = { + # Test identification + "TEST_RUN_ID": test_id, + "TARGET_NAME": target["name"], + "TARGET_INDEX": str(target_index), + "RUN_MODE": run_mode, + "BENCHMARK": benchmark, + "DATABASE_TYPE": db_type, + # Database connection + "USERNAME": target["username"], + "PASSWORD": target["password"], + "HOST": target["host"], + # Global settings + "TMP": "/tmp", + "TMPDIR": "/tmp", + } + + mssql_cfg = config.targets.defaults.mssql or MssqlConfig() + env["USE_BCP"] = _bool_str(mssql_cfg.tprocc.use_bcp) + + if db_type == "mssql": + env.update(_mssql_connection_env(target, mssql_cfg)) + elif db_type == "oracle": + env.update(_oracle_env(target)) + elif db_type == "postgres": + env.update(_postgres_env(target, benchmark)) + + if benchmark == "tprocc": + env.update(_tprocc_env(config, target, db_type)) + elif benchmark == "tproch": + env.update(_tproch_env(config, target, db_type, mssql_cfg)) + + if config.storage_metrics.enabled: + env.update(_pure_storage_env(config, target_index)) + + return env + + +def _mssql_connection_env(target: dict, mssql_cfg: MssqlConfig) -> dict[str, str]: + """SQL Server connection variables.""" + conn = mssql_cfg.connection + return { + "SQL_SERVER_HOST": target["host"], + "MSSQLS_TCP": _bool_str(conn.tcp), + "MSSQLS_PORT": str(mssql_cfg.port), + "MSSQLS_AUTHENTICATION": conn.authentication, + "MSSQLS_AZURE": "false", + "MSSQLS_LINUX_ODBC": conn.odbc_driver, + "MSSQLS_ODBC_DRIVER": conn.odbc_driver, + "MSSQLS_ENCRYPT_CONNECTION": _bool_str(conn.encrypt_connection), + "MSSQLS_TRUST_SERVER_CERT": _bool_str(conn.trust_server_cert), + } + + +def _oracle_env(target: dict) -> dict[str, str]: + """Oracle connection and schema variables.""" + oracle = target.get("oracle", {}) + tprocc = oracle.get("tprocc", {}) + tproch = oracle.get("tproch", {}) + password = target["password"] + + env = { + "ORACLE_SERVICE": oracle.get("service", "ORCL"), + "ORACLE_PORT": str(oracle.get("port", 1521)), + "ORACLE_TABLESPACE": oracle.get("tablespace", "USERS"), + "ORACLE_TEMP_TABLESPACE": oracle.get("temp_tablespace", "TEMP"), + "TPROCC_USER": tprocc.get("user") or "tpcc", + "TPROCC_PASSWORD": tprocc.get("password") or password, + "TPROCH_USER": tproch.get("user") or "tpch", + "TPROCH_PASSWORD": tproch.get("password") or password, + "TPROCH_DEGREE_OF_PARALLEL": str(tproch.get("degree_of_parallel", 8)), + } + if oracle.get("sid"): + env["ORACLE_SID"] = oracle["sid"] + return env + + +def _postgres_env(target: dict, benchmark: str) -> dict[str, str]: + """PostgreSQL connection and schema variables.""" + pg = target.get("postgres", {}) + tprocc = pg.get("tprocc", {}) + tproch = pg.get("tproch", {}) + + env = { + "PG_PORT": str(pg.get("port", 5432)), + "PG_SSLMODE": pg.get("sslmode", "prefer"), + } + if pg.get("tablespace"): + env["PG_TABLESPACE"] = pg["tablespace"] + + if benchmark == "tprocc": + env["TPROCC_USER"] = tprocc.get("user") or "tpcc" + env["TPROCC_PASSWORD"] = tprocc.get("password") or "tpcc" + env["TPROCC_DATABASE_NAME"] = tprocc.get("database_name") or "tpcc" + # Build and run must agree: the driver calls stored procedures that + # only exist if the schema was built with them. + env["PG_STOREDPROCS"] = _bool_str(tprocc.get("stored_procedures", True)) + env["PG_PARTITION"] = _bool_str(tprocc.get("partition", False)) + env["PG_VACUUM"] = _bool_str(tprocc.get("vacuum", False)) + else: + env["TPROCH_USER"] = tproch.get("user") or "tpch" + env["TPROCH_PASSWORD"] = tproch.get("password") or "tpch" + env["TPROCH_DATABASE_NAME"] = tproch.get("database_name") or "tpch" + env["PG_MAX_PARALLEL_WORKERS"] = str(tproch.get("max_parallel_workers", 8)) + + return env + + +def _tprocc_env( + config: HammerDBScaleConfig, target: dict, db_type: str +) -> dict[str, str]: + """TPC-C benchmark variables.""" + tc = config.hammerdb.tprocc + env = { + "TPROCC_DRIVER": get_db_driver(db_type), + "TPROCC_BUILD_VIRTUAL_USERS": str(tc.build_virtual_users), + "WAREHOUSES": str(tc.warehouses), + "TPROCC_DRIVER_TYPE": tc.driver, + "TPROCC_ALLWAREHOUSE": _bool_str(tc.all_warehouses), + "VIRTUAL_USERS": str(tc.load_virtual_users), + "RAMPUP": str(tc.rampup), + "DURATION": str(tc.duration), + "TOTAL_ITERATIONS": str(tc.total_iterations), + "TPROCC_LOG_TO_TEMP": "0", + "TPROCC_USE_TRANSACTION_COUNTER": "true", + "TPROCC_CHECKPOINT": _bool_str(tc.checkpoint), + "TPROCC_TIMEPROFILE": _bool_str(tc.time_profile), + } + if db_type == "mssql": + env["TPROCC_DATABASE_NAME"] = target.get("tprocc", {}).get( + "databaseName", "tpcc" + ) + return env + + +def _tproch_env( + config: HammerDBScaleConfig, + target: dict, + db_type: str, + mssql_cfg: MssqlConfig, +) -> dict[str, str]: + """TPC-H benchmark variables.""" + th = config.hammerdb.tproch + env = { + "TPROCH_DRIVER": get_db_driver(db_type), + "TPROCH_SCALE_FACTOR": str(th.scale_factor), + "TPROCH_BUILD_THREADS": str(th.build_threads), + "TPROCH_BUILD_VIRTUAL_USERS": str(th.build_virtual_users), + "TPROCH_VIRTUAL_USERS": str(th.load_virtual_users), + "TPROCH_TOTAL_QUERYSETS": str(th.total_querysets), + "TPROCH_LOG_TO_TEMP": "1", + } + if db_type == "mssql": + env["TPROCH_DATABASE_NAME"] = target.get("tproch", {}).get( + "databaseName", "tpch" + ) + env["TPROCH_USE_CLUSTERED_COLUMNSTORE"] = _bool_str( + mssql_cfg.tproch.use_clustered_columnstore + ) + env["TPROCH_MAXDOP"] = str(mssql_cfg.tproch.maxdop) + return env + + +def _pure_storage_env(config: HammerDBScaleConfig, target_index: int) -> dict[str, str]: + """Pure Storage metrics collection variables. + + Only the first target collects metrics, matching the Helm template, so that + N targets do not each hammer the array's REST API with identical queries. + """ + pure = config.storage_metrics.pure + env = { + "PURE_ENABLED": "true", + "PURE_COLLECT_METRICS": "true" if target_index == 0 else "false", + "PURE_HOST": pure.host, + "PURE_API_TOKEN": pure.api_token, + "PURE_INTERVAL": str(pure.poll_interval), + "PURE_NO_VERIFY_SSL": "false" if pure.verify_ssl else "true", + "PURE_API_VERSION": pure.api_version, + "PURE_OUTPUT": "/tmp/pure_metrics.json", + } + if pure.volume: + env["PURE_VOLUME"] = pure.volume + if pure.duration is not None: + env["PURE_DURATION"] = str(pure.duration) + return env diff --git a/src/hammerdb_scale/runtime/kubernetes.py b/src/hammerdb_scale/runtime/kubernetes.py new file mode 100644 index 0000000..2701d3a --- /dev/null +++ b/src/hammerdb_scale/runtime/kubernetes.py @@ -0,0 +1,126 @@ +"""Kubernetes execution backend. + +A thin adapter over the existing helm and kubectl code paths. It exists so the +CLI can treat Kubernetes and local containers uniformly; the underlying +behaviour is unchanged from before the backend abstraction was introduced. +""" + +from __future__ import annotations + +import shutil + +from hammerdb_scale.constants import get_chart_path +from hammerdb_scale.helm.deployer import helm_install, helm_list, helm_uninstall +from hammerdb_scale.k8s.jobs import ( + discover_jobs, + get_job_database_type, + get_job_duration, + get_job_logs, + get_job_status, + get_job_target_host, + get_job_target_name, +) +from hammerdb_scale.k8s.naming import generate_release_name, generate_run_hash +from hammerdb_scale.runtime.base import WorkloadRef + + +class KubernetesBackend: + """Runs HammerDB workloads as Kubernetes Jobs via Helm.""" + + name = "kubernetes" + + def __init__(self, namespace: str, config=None) -> None: + self.namespace = namespace + self.config = config + + def preflight(self) -> list[str]: + """Check that helm and kubectl are present and a cluster is reachable.""" + problems: list[str] = [] + for tool, url in ( + ("helm", "https://helm.sh/docs/intro/install/"), + ("kubectl", "https://kubernetes.io/docs/tasks/tools/"), + ): + if not shutil.which(tool): + problems.append(f"{tool} not found. Install from {url}") + return problems + + def deploy( + self, + targets: list[dict], + env_per_target: list[dict[str, str]], + *, + test_id: str, + phase: str, + benchmark: str, + image: str, + pull_policy: str = "Always", + dry_run: bool = False, + ) -> list[str]: + """Install a Helm release that creates one Job per target. + + The Helm chart derives the container environment from values, so + ``env_per_target`` is unused here. It stays in the signature because the + container backend needs it and both must satisfy one protocol. + """ + from hammerdb_scale.helm.values import generate_helm_values + + if self.config is None: + raise ValueError("KubernetesBackend requires a config to render values") + + run_hash = generate_run_hash(self.config.name, test_id) + release = generate_release_name(phase, run_hash) + values = generate_helm_values(self.config, phase, benchmark, test_id) + + helm_install(release, get_chart_path(), self.namespace, values, dry_run=dry_run) + + helm_phase = "load" if phase == "run" else phase + return [f"hdb-{helm_phase}-{i:02d}-{run_hash}" for i in range(len(targets))] + + def list_workloads( + self, test_id: str, phase: str | None = None + ) -> list[WorkloadRef]: + """Find Jobs belonging to a test run.""" + jobs = discover_jobs(self.namespace, test_id, phase=phase) + workloads = [] + for job in jobs: + annotations = job.get("metadata", {}).get("annotations", {}) + try: + index = int(annotations.get("hammerdb.io/target-index", 0)) + except (TypeError, ValueError): + index = 0 + workloads.append( + WorkloadRef( + name=job.get("metadata", {}).get("name", ""), + target_name=get_job_target_name(job), + target_host=get_job_target_host(job), + database_type=get_job_database_type(job), + index=index, + status=get_job_status(job), + duration_seconds=get_job_duration(job), + ) + ) + return sorted(workloads, key=lambda w: w.index) + + def get_logs(self, workload_name: str, tail: int | None = None) -> str: + """Fetch logs from a Job's pod.""" + return get_job_logs(self.namespace, workload_name, tail=tail) + + def remove(self, test_id: str | None = None, everything: bool = False) -> int: + """Uninstall Helm releases for a test run, or all managed releases.""" + releases = helm_list(self.namespace) + removed = 0 + for release in releases: + name = release.get("name", "") + if not name.startswith("hdb-"): + continue + if not everything and test_id: + jobs = discover_jobs(self.namespace, test_id) + job_names = {j.get("metadata", {}).get("name", "") for j in jobs} + if not any(name in jn for jn in job_names): + continue + try: + helm_uninstall(name, self.namespace) + removed += 1 + except Exception: + continue + return removed diff --git a/src/hammerdb_scale/runtime/preflight.py b/src/hammerdb_scale/runtime/preflight.py new file mode 100644 index 0000000..ea64107 --- /dev/null +++ b/src/hammerdb_scale/runtime/preflight.py @@ -0,0 +1,161 @@ +"""In-cluster connectivity checking. + +``validate`` normally tests database reachability from the workstation, but the +benchmark runs from inside a pod or container. Those are different network +positions: a config can pass validation cleanly and then have every job fail on +connectivity, which is the worst kind of failure because it arrives late and +points nowhere useful. + +This module runs the same reachability test from where the workload will +actually run. +""" + +from __future__ import annotations + +import json +import subprocess + +# Plain TCP connect, no database client needed, so this works with any image. +_PROBE = r""" +import json, socket, sys +results = [] +for name, host, port in json.loads(sys.argv[1]): + try: + s = socket.create_connection((host, int(port)), timeout=8) + s.close() + results.append({"name": name, "ok": True, "error": ""}) + except Exception as exc: + results.append({"name": name, "ok": False, "error": str(exc)}) +print("PROBE_RESULT:" + json.dumps(results)) +""" + + +# Fallback listening port per database type, used only when the config does +# not carry one. Probing the wrong port reports a healthy fleet as unreachable, +# which reads as "the tool is broken" rather than "the check is wrong". +_DEFAULT_PORTS = {"oracle": 1521, "mssql": 1433, "postgres": 5432} + + +def _target_endpoints(config) -> list[tuple[str, str, int]]: + """Build (name, host, port) for every target.""" + from hammerdb_scale.config.defaults import expand_targets + + defaults = config.targets.defaults + endpoints = [] + for target in expand_targets(config): + db_type = target["type"] + fallback = _DEFAULT_PORTS.get(db_type, 1433) + + # The per-target block wins; then the shared defaults block for that + # database; then the well-known port. + port = (target.get(db_type) or {}).get("port") + if port is None: + block = getattr(defaults, db_type, None) + port = getattr(block, "port", None) if block else None + endpoints.append((target["name"], target["host"], int(port or fallback))) + return endpoints + + +def _parse_probe_output(stdout: str) -> list[dict] | None: + """Pull the JSON result line out of pod or container output.""" + for line in stdout.splitlines(): + line = line.strip() + if line.startswith("PROBE_RESULT:"): + try: + return json.loads(line[len("PROBE_RESULT:") :]) + except json.JSONDecodeError: + return None + return None + + +def check_from_kubernetes(config, namespace: str, image: str) -> list[dict]: + """Run the probe as a one-shot pod in the target namespace. + + Uses the benchmark image so the test traverses the same network path, + namespace and policies the real jobs will. + """ + endpoints = _target_endpoints(config) + payload = json.dumps([[n, h, p] for n, h, p in endpoints]) + + overrides = json.dumps( + { + "spec": { + "securityContext": { + "runAsNonRoot": True, + "seccompProfile": {"type": "RuntimeDefault"}, + }, + "containers": [ + { + "name": "preflight", + "image": image, + "command": ["python3", "-c", _PROBE, payload], + "securityContext": { + "allowPrivilegeEscalation": False, + "capabilities": {"drop": ["ALL"]}, + }, + } + ], + } + } + ) + + result = subprocess.run( + [ + "kubectl", + "run", + "hammerdb-preflight", + "-n", + namespace, + "--rm", + "--attach", + "--restart=Never", + "--quiet", + f"--image={image}", + "--override-type=strategic", + f"--overrides={overrides}", + "--command", + "--", + "python3", + "-c", + _PROBE, + payload, + ], + capture_output=True, + text=True, + timeout=300, + ) + + parsed = _parse_probe_output(result.stdout) + if parsed is None: + detail = (result.stderr or result.stdout or "no output").strip() + raise RuntimeError(f"In-cluster probe produced no result: {detail[:400]}") + return parsed + + +def check_from_container(config, runtime: str, image: str) -> list[dict]: + """Run the probe in a local container using the benchmark image.""" + endpoints = _target_endpoints(config) + payload = json.dumps([[n, h, p] for n, h, p in endpoints]) + + result = subprocess.run( + [ + runtime, + "run", + "--rm", + "--entrypoint", + "python3", + image, + "-c", + _PROBE, + payload, + ], + capture_output=True, + text=True, + timeout=300, + ) + + parsed = _parse_probe_output(result.stdout) + if parsed is None: + detail = (result.stderr or result.stdout or "no output").strip() + raise RuntimeError(f"Container probe produced no result: {detail[:400]}") + return parsed diff --git a/src/hammerdb_scale/runtime/resolve.py b/src/hammerdb_scale/runtime/resolve.py new file mode 100644 index 0000000..67f1b4c --- /dev/null +++ b/src/hammerdb_scale/runtime/resolve.py @@ -0,0 +1,153 @@ +"""Backend-agnostic test-run resolution and completion waiting. + +The Kubernetes path resolves a test ID by inspecting Helm releases; the +container path inspects container labels. Both then fall back to the local +results directory. Keeping that logic here means CLI commands do not branch on +backend type. +""" + +from __future__ import annotations + +import time +from pathlib import Path + +from hammerdb_scale.constants import POLL_INTERVAL, NoResultsError +from hammerdb_scale.output import console, print_error, print_success, print_warning +from hammerdb_scale.runtime.base import STATUS_COMPLETED, STATUS_FAILED + + +def resolve_test_id( + backend, + cli_id: str | None, + *, + results_dir: Path = Path("./results"), + deployment_name: str | None = None, +) -> str: + """Decide which test run a command should act on. + + Order of precedence: an explicit --id, then the most recent run the backend + knows about, then the most recent locally stored results. + """ + if cli_id: + return cli_id + + backend_id = _find_backend_test_id(backend, deployment_name) + if backend_id: + return backend_id + + local_ids = _find_local_test_ids(results_dir) + if deployment_name and len(local_ids) > 1: + filtered = [t for t in local_ids if t.startswith(deployment_name + "-")] + if filtered: + local_ids = filtered + + if local_ids: + return sorted(local_ids)[-1] + + raise NoResultsError( + "No test runs found. Run a benchmark first, or pass --id explicitly." + ) + + +def _find_backend_test_id(backend, deployment_name: str | None) -> str | None: + """Ask the backend for its most recent test ID. + + A backend that cannot be queried is not the same as a backend with no + runs, so the reason is surfaced as a warning rather than swallowed. The + caller still falls back to locally stored results. + """ + finder = getattr(backend, "find_test_ids", None) + if finder is not None: + try: + for test_id in finder(): + if deployment_name and not test_id.startswith(deployment_name + "-"): + continue + return test_id + except Exception as e: + print_warning(f"Could not list runs from {backend.name}: {e}") + return None + + # Kubernetes backend: reuse the existing Helm-release-based lookup. + try: + from hammerdb_scale.k8s.jobs import _find_most_recent_k8s_test_id + + return _find_most_recent_k8s_test_id(backend.namespace, deployment_name) + except Exception as e: + print_warning(f"Could not list runs from Kubernetes: {e}") + return None + + +def _find_local_test_ids(results_dir: Path) -> list[str]: + """Scan the results directory for previously stored runs.""" + if not results_dir.exists(): + return [] + return [ + d.name + for d in results_dir.iterdir() + if d.is_dir() and (d / "summary.json").exists() + ] + + +def wait_for_completion( + backend, + test_id: str, + phase: str, + timeout: int, + *, + show_errors: bool = True, +) -> bool: + """Poll until every workload finishes or the timeout elapses. + + Returns True only if all workloads completed successfully. On failure the + error extracted from each failing workload's log is printed, so the user + does not have to go and read logs to find out what went wrong. + """ + start = time.time() + while time.time() - start < timeout: + workloads = backend.list_workloads(test_id, phase=phase) + if not workloads: + time.sleep(POLL_INTERVAL) + continue + + completed = sum(1 for w in workloads if w.status == STATUS_COMPLETED) + failed = sum(1 for w in workloads if w.status == STATUS_FAILED) + total = len(workloads) + + console.print(f" [{completed}/{total}] completed, {failed} failed", end="\r") + + if completed + failed >= total: + console.print() + for w in workloads: + if w.status == STATUS_FAILED: + print_error(f"{w.target_name} Failed") + if show_errors: + _print_failure_reason(backend, w) + else: + print_success(f"{w.target_name} {w.status}") + return failed == 0 + + time.sleep(POLL_INTERVAL) + + console.print(f"\n[yellow]Timeout ({timeout}s) reached.[/yellow]") + return False + + +def _print_failure_reason(backend, workload) -> None: + """Surface the database error inline instead of making the user dig.""" + try: + from hammerdb_scale.results.parsers import get_parser + + log_text = backend.get_logs(workload.name, tail=200) + if not log_text: + return + try: + error = get_parser(workload.database_type).detect_error(log_text) + except ValueError: + error = None + if error: + console.print(f" [red]{error.strip()}[/red]") + console.print( + f" [dim]Full log: hammerdb-scale logs --target {workload.target_name}[/dim]" + ) + except Exception: + return diff --git a/src/hammerdb_scale/wizard.py b/src/hammerdb_scale/wizard.py index 0f1bb23..882978f 100644 --- a/src/hammerdb_scale/wizard.py +++ b/src/hammerdb_scale/wizard.py @@ -23,6 +23,19 @@ def _step_header(step: int, total: int, title: str, subtitle: str) -> None: ) +def _detect_local_runtime() -> str | None: + """Which container runtime is installed here, if any. + + Used only to order the backend choices so the obvious answer is first. + """ + import shutil + + for candidate in ("podman", "docker"): + if shutil.which(candidate): + return candidate + return None + + def _prompt_required(prompt_text: str, **kwargs: object) -> str: """Prompt for a non-empty string, re-asking if blank.""" while True: @@ -79,7 +92,10 @@ def _build_summary_table(values: dict) -> Table: else: table.add_row("Scale factor", str(values.get("scale_factor", 1))) - table.add_row("Namespace", values.get("namespace", "hammerdb")) + backend = values.get("backend_str", "kubernetes") + table.add_row("Workers run on", backend) + if values.get("namespace"): + table.add_row("Namespace", values["namespace"]) storage = values.get("storage_metrics") if storage and storage.get("enabled"): @@ -139,6 +155,27 @@ def run_wizard() -> dict | None: console.print() name = _prompt_required("Deployment name") + console.print() + console.print("[dim]Where should the HammerDB workers run?[/dim]") + detected = _detect_local_runtime() + # Lead with whatever is actually installed: the common case is a + # single host with one runtime, and that user should not have to + # think about the choice. Kubernetes stays last either way, so the + # menu positions of the container options are the only ones that + # move. + container_choices = [ + ("podman", "podman on this machine"), + ("docker", "docker on this machine"), + ] + if detected: + container_choices.sort(key=lambda choice: choice[0] != detected) + backend_str = _select_option( + "Run workers on", + container_choices + + [("kubernetes", "Kubernetes cluster (needs helm and kubectl)")], + ) + is_kubernetes = backend_str == "kubernetes" + # ── Step 2: Database & Benchmark ──────────────────────────── _step_header( 2, @@ -150,7 +187,11 @@ def run_wizard() -> dict | None: db_type_str = _select_option( "Database type", - [("oracle", "Oracle"), ("mssql", "Microsoft SQL Server")], + [ + ("mssql", "Microsoft SQL Server"), + ("postgres", "PostgreSQL"), + ("oracle", "Oracle (needs a locally built image)"), + ], ) console.print() @@ -252,11 +293,18 @@ def run_wizard() -> dict | None: 6, total_steps, "Infrastructure", - "Kubernetes and storage settings.", + "Cluster and storage settings." + if is_kubernetes + else "Storage metrics collection.", ) - console.print() - namespace = Prompt.ask("Kubernetes namespace", default="hammerdb") + # Only ask about a namespace when the answer can matter. Asking a + # container-backend user for Kubernetes settings makes the tool look + # like it needs a cluster when it does not. + namespace = None + if is_kubernetes: + console.print() + namespace = Prompt.ask("Kubernetes namespace", default="hammerdb") storage_metrics = None console.print() @@ -291,6 +339,7 @@ def run_wizard() -> dict | None: "warehouses": warehouses, "scale_factor": scale_factor, "namespace": namespace, + "backend_str": backend_str, "storage_metrics": storage_metrics, } diff --git a/templates b/templates new file mode 120000 index 0000000..7e95878 --- /dev/null +++ b/templates @@ -0,0 +1 @@ +src/hammerdb_scale/chart/templates \ No newline at end of file diff --git a/templates/_helpers.tpl b/templates/_helpers.tpl deleted file mode 100644 index 4fd29c3..0000000 --- a/templates/_helpers.tpl +++ /dev/null @@ -1,158 +0,0 @@ -{{/* -Expand the name of the chart. -*/}} -{{- define "hammerdb-scale-test.name" -}} -{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Create a default fully qualified app name. -*/}} -{{- define "hammerdb-scale-test.fullname" -}} -{{- if .Values.fullnameOverride }} -{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- $name := default .Chart.Name .Values.nameOverride }} -{{- if contains $name .Release.Name }} -{{- .Release.Name | trunc 63 | trimSuffix "-" }} -{{- else }} -{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} -{{- end }} -{{- end }} -{{- end }} - -{{/* -Create chart name and version as used by the chart label. -*/}} -{{- define "hammerdb-scale-test.chart" -}} -{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} -{{- end }} - -{{/* -Common labels -*/}} -{{- define "hammerdb-scale-test.labels" -}} -helm.sh/chart: {{ include "hammerdb-scale-test.chart" . }} -{{ include "hammerdb-scale-test.selectorLabels" . }} -{{- if .Chart.AppVersion }} -app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} -{{- end }} -app.kubernetes.io/managed-by: {{ .Release.Service }} -{{- end }} - -{{/* -Selector labels -*/}} -{{- define "hammerdb-scale-test.selectorLabels" -}} -app.kubernetes.io/name: {{ include "hammerdb-scale-test.name" . }} -app.kubernetes.io/instance: {{ .Release.Name }} -{{- end }} - -{{/* -Get database driver for a given type -*/}} -{{- define "hammerdb-scale-test.dbDriver" -}} -{{- $dbType := . -}} -{{- if eq $dbType "mssql" -}} -mssqls -{{- else if eq $dbType "postgres" -}} -pg -{{- else if eq $dbType "oracle" -}} -oracle -{{- else if eq $dbType "mysql" -}} -mysql -{{- else -}} -{{- fail (printf "Unsupported database type: %s" $dbType) -}} -{{- end -}} -{{- end }} - -{{/* -Get merged TPC-C configuration for a target -Merges global hammerdb.tprocc defaults with target-specific overrides -Usage: include "hammerdb-scale-test.tprocc-config" (dict "root" $ "target" .) -*/}} -{{- define "hammerdb-scale-test.tprocc-config" -}} -{{- $global := .root.Values.hammerdb.tprocc | default dict -}} -{{- $target := .target.tprocc | default dict -}} -{{- $merged := merge (deepCopy $target) $global -}} -{{- toYaml $merged -}} -{{- end }} - -{{/* -Get merged TPC-H configuration for a target -Usage: include "hammerdb-scale-test.tproch-config" (dict "root" $ "target" .) -*/}} -{{- define "hammerdb-scale-test.tproch-config" -}} -{{- $global := .root.Values.hammerdb.tproch | default dict -}} -{{- $target := .target.tproch | default dict -}} -{{- $merged := merge (deepCopy $target) $global -}} -{{- toYaml $merged -}} -{{- end }} - -{{/* -Get merged connection configuration for a target -Usage: include "hammerdb-scale-test.connection-config" (dict "root" $ "target" .) -*/}} -{{- define "hammerdb-scale-test.connection-config" -}} -{{- $global := .root.Values.hammerdb.connection | default dict -}} -{{- $target := .target.connection | default dict -}} -{{- $merged := merge (deepCopy $target) $global -}} -{{- toYaml $merged -}} -{{- end }} - -{{/* -Get merged Oracle configuration for a target -Merges databases.oracle defaults with target-specific Oracle overrides -Usage: include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" .) -Returns: service, tablespace, tempTablespace, tproccUser, tprochUser, degreeOfParallel -*/}} -{{- define "hammerdb-scale-test.oracle-config" -}} -{{- $oracleDefaults := .root.Values.databases.oracle | default dict -}} -{{- $target := .target -}} - -{{/* Service name or SID */}} -service: {{ $target.oracleService | default $oracleDefaults.service | default "ORCL" | quote }} -{{- if $target.oracleSid }} -sid: {{ $target.oracleSid | quote }} -{{- end }} - -{{/* Tablespaces */}} -tablespace: {{ $target.oracleTablespace | default $oracleDefaults.tablespace | default "USERS" | quote }} -tempTablespace: {{ $target.oracleTempTablespace | default $oracleDefaults.tempTablespace | default "TEMP" | quote }} -port: {{ $target.oraclePort | default $oracleDefaults.port | default 1521 }} - -{{/* TPC-C user and password */}} -{{- if $target.tprocc }} -tproccUser: {{ $target.tprocc.user | default (($oracleDefaults.tprocc | default dict).user) | default "tpcc" | quote }} -tproccPassword: {{ $target.tprocc.password | default (($oracleDefaults.tprocc | default dict).password) | default $target.password | quote }} -{{- else }} -tproccUser: {{ (($oracleDefaults.tprocc | default dict).user) | default "tpcc" | quote }} -tproccPassword: {{ (($oracleDefaults.tprocc | default dict).password) | default $target.password | quote }} -{{- end }} - -{{/* TPC-H user, password, and parallelism */}} -{{- if $target.tproch }} -tprochUser: {{ $target.tproch.user | default (($oracleDefaults.tproch | default dict).user) | default "tpch" | quote }} -tprochPassword: {{ $target.tproch.password | default (($oracleDefaults.tproch | default dict).password) | default $target.password | quote }} -degreeOfParallel: {{ $target.tproch.degreeOfParallel | default (($oracleDefaults.tproch | default dict).degreeOfParallel) | default 8 }} -{{- else }} -tprochUser: {{ (($oracleDefaults.tproch | default dict).user) | default "tpch" | quote }} -tprochPassword: {{ (($oracleDefaults.tproch | default dict).password) | default $target.password | quote }} -degreeOfParallel: {{ (($oracleDefaults.tproch | default dict).degreeOfParallel) | default 8 }} -{{- end }} -{{- end }} - -{{/* -Get list of database types used in targets -Returns a JSON object with a "types" array containing unique database types -Usage: include "hammerdb-scale-test.usedDatabaseTypes" . | fromJson -*/}} -{{- define "hammerdb-scale-test.usedDatabaseTypes" -}} -{{- $types := list -}} -{{- range .Values.targets -}} - {{- if not (has .type $types) -}} - {{- $types = append $types .type -}} - {{- end -}} -{{- end -}} -{{- dict "types" $types | toJson -}} -{{- end }} diff --git a/templates/configmap-mssql.yaml b/templates/configmap-mssql.yaml deleted file mode 100644 index bc02f2d..0000000 --- a/templates/configmap-mssql.yaml +++ /dev/null @@ -1,28 +0,0 @@ -{{- $hasMssql := false -}} -{{- range .Values.targets -}} - {{- if eq .type "mssql" -}} - {{- $hasMssql = true -}} - {{- end -}} -{{- end -}} -{{- if $hasMssql }} -# ConfigMap for SQL Server (MSSQL) Scripts -apiVersion: v1 -kind: ConfigMap -metadata: - name: {{ include "hammerdb-scale-test.fullname" . }}-scripts-mssql - labels: - {{- include "hammerdb-scale-test.labels" . | nindent 4 }} - hammerdb.io/database-type: mssql -data: - # HammerDB TCL scripts - {{- range $path, $_ := .Files.Glob "scripts/mssql/*.tcl" }} - {{ base $path }}: |- -{{ $.Files.Get $path | indent 4 }} - {{- end }} - - # Pure Storage metrics collector - {{- if .Values.pureStorage.enabled }} - collect_pure_metrics.py: |- -{{ .Files.Get "scripts/collect_pure_metrics.py" | indent 4 }} - {{- end }} -{{- end }} diff --git a/templates/job-hammerdb-worker.yaml b/templates/job-hammerdb-worker.yaml deleted file mode 100644 index 60bcbe0..0000000 --- a/templates/job-hammerdb-worker.yaml +++ /dev/null @@ -1,240 +0,0 @@ -{{- if not (has .Values.testRun.phase (list "build" "load")) -}} - {{- fail "testRun.phase must be 'build' or 'load'" -}} -{{- end -}} -{{- if not (has .Values.testRun.benchmark (list "tprocc" "tproch")) -}} - {{- fail "testRun.benchmark must be 'tprocc' or 'tproch'" -}} -{{- end -}} -{{- range $index, $target := .Values.targets }} -{{- $tproccConfig := include "hammerdb-scale-test.tprocc-config" (dict "root" $ "target" $target) | fromYaml }} -{{- $tprochConfig := include "hammerdb-scale-test.tproch-config" (dict "root" $ "target" $target) | fromYaml }} -{{- $connConfig := include "hammerdb-scale-test.connection-config" (dict "root" $ "target" $target) | fromYaml }} -{{- $oracleConfig := include "hammerdb-scale-test.oracle-config" (dict "root" $ "target" $target) | fromYaml }} ---- -apiVersion: batch/v1 -kind: Job -metadata: - {{- if and $.Values.naming $.Values.naming.useV2Naming }} - name: "{{ $.Values.naming.prefix }}-{{ $.Values.testRun.phase }}-{{ printf "%02d" $index }}-{{ $.Values.naming.runHash }}" - {{- else }} - name: {{ printf "%s-%s-%s-%s" (include "hammerdb-scale-test.fullname" $) $.Values.testRun.phase $target.name $.Values.testRun.id | trunc 63 | trimSuffix "-" }} - {{- end }} - labels: - {{- include "hammerdb-scale-test.labels" $ | nindent 4 }} - app.kubernetes.io/component: worker - hammerdb.io/test-run: {{ $.Values.testRun.id | quote }} - hammerdb.io/phase: {{ $.Values.testRun.phase | quote }} - hammerdb.io/target: {{ $target.name | quote }} - hammerdb.io/target-name: {{ $target.name | quote }} - hammerdb.io/benchmark: {{ $.Values.testRun.benchmark | quote }} - hammerdb.io/database-type: {{ $target.type | quote }} - {{- if $.Values.extraLabels }} - {{- range $key, $val := $.Values.extraLabels }} - {{ $key }}: {{ $val | quote }} - {{- end }} - {{- end }} - annotations: - hammerdb.io/target-host: {{ $target.host | quote }} - {{- if $.Values.naming }} - hammerdb.io/target-index: {{ $index | quote }} - {{- end }} -spec: - backoffLimit: 1 - ttlSecondsAfterFinished: {{ $.Values.kubernetes.job_ttl | default 86400 }} - template: - metadata: - labels: - {{- include "hammerdb-scale-test.selectorLabels" $ | nindent 8 }} - app.kubernetes.io/component: worker - hammerdb.io/test-run: {{ $.Values.testRun.id | quote }} - hammerdb.io/phase: {{ $.Values.testRun.phase | quote }} - hammerdb.io/target: {{ $target.name | quote }} - spec: - hostNetwork: {{ $.Values.global.hostNetwork | default false }} - restartPolicy: Never - containers: - - name: hammerdb - image: "{{ $.Values.global.image.repository }}:{{ $.Values.global.image.tag }}" - imagePullPolicy: {{ $.Values.global.image.pullPolicy }} - env: - # Test identification - - name: TEST_RUN_ID - value: {{ $.Values.testRun.id | quote }} - - name: TARGET_NAME - value: {{ $target.name | quote }} - - name: TARGET_INDEX - value: {{ $index | quote }} - - name: RUN_MODE - value: {{ $.Values.testRun.phase | quote }} - - name: BENCHMARK - value: {{ $.Values.testRun.benchmark | quote }} - - name: DATABASE_TYPE - value: {{ $target.type | quote }} - - # Database connection - - name: USERNAME - value: {{ $target.username | quote }} - - name: PASSWORD - value: {{ $target.password | quote }} - - name: HOST - value: {{ $target.host | quote }} - - # Global settings - - name: USE_BCP - value: {{ $tproccConfig.use_bcp | quote }} - - name: TMP - value: /tmp - - name: TMPDIR - value: /tmp - - {{- if eq $target.type "mssql" }} - # SQL Server specific connection config - - name: SQL_SERVER_HOST - value: {{ $target.host | quote }} - - name: MSSQLS_TCP - value: {{ $connConfig.tcp | quote }} - - name: MSSQLS_PORT - value: {{ $connConfig.port | quote }} - - name: MSSQLS_AUTHENTICATION - value: {{ $connConfig.authentication | quote }} - - name: MSSQLS_AZURE - value: {{ $connConfig.azure | quote }} - - name: MSSQLS_LINUX_ODBC - value: {{ $connConfig.linux_odbc | quote }} - - name: MSSQLS_ODBC_DRIVER - value: {{ $connConfig.odbc_driver | quote }} - - name: MSSQLS_ENCRYPT_CONNECTION - value: {{ $connConfig.encrypt_connection | quote }} - - name: MSSQLS_TRUST_SERVER_CERT - value: {{ $connConfig.trust_server_cert | quote }} - {{- end }} - - {{- if eq $target.type "oracle" }} - # Oracle specific connection config (merged from defaults and target overrides) - - name: ORACLE_SERVICE - value: {{ $oracleConfig.service }} - - name: ORACLE_PORT - value: {{ $oracleConfig.port | quote }} - {{- if $oracleConfig.sid }} - - name: ORACLE_SID - value: {{ $oracleConfig.sid }} - {{- end }} - - name: ORACLE_TABLESPACE - value: {{ $oracleConfig.tablespace }} - - name: ORACLE_TEMP_TABLESPACE - value: {{ $oracleConfig.tempTablespace }} - - name: TPROCC_USER - value: {{ $oracleConfig.tproccUser }} - - name: TPROCC_PASSWORD - value: {{ $oracleConfig.tproccPassword }} - - name: TPROCH_USER - value: {{ $oracleConfig.tprochUser }} - - name: TPROCH_PASSWORD - value: {{ $oracleConfig.tprochPassword }} - - name: TPROCH_DEGREE_OF_PARALLEL - value: {{ $oracleConfig.degreeOfParallel | quote }} - {{- end }} - - {{- if eq $.Values.testRun.benchmark "tprocc" }} - # TPC-C Configuration (from merged config) - {{- if eq $target.type "mssql" }} - - name: TPROCC_DATABASE_NAME - value: {{ $target.tprocc.databaseName | quote }} - {{- end }} - - name: TPROCC_DRIVER - value: {{ include "hammerdb-scale-test.dbDriver" $target.type | quote }} - - name: TPROCC_BUILD_VIRTUAL_USERS - value: {{ $tproccConfig.build_num_vu | quote }} - - name: WAREHOUSES - value: {{ $tproccConfig.warehouses | quote }} - - name: TPROCC_DRIVER_TYPE - value: {{ $tproccConfig.driver | quote }} - - name: TPROCC_ALLWAREHOUSE - value: {{ $tproccConfig.allwarehouse | quote }} - - name: VIRTUAL_USERS - value: {{ $tproccConfig.load_num_vu | quote }} - - name: RAMPUP - value: {{ $tproccConfig.rampup | quote }} - - name: DURATION - value: {{ $tproccConfig.duration | quote }} - - name: TOTAL_ITERATIONS - value: {{ $tproccConfig.total_iterations | quote }} - - name: TPROCC_LOG_TO_TEMP - value: "0" - - name: TPROCC_USE_TRANSACTION_COUNTER - value: "true" - - name: TPROCC_CHECKPOINT - value: {{ $tproccConfig.checkpoint | quote }} - - name: TPROCC_TIMEPROFILE - value: {{ $tproccConfig.timeprofile | quote }} - {{- end }} - - {{- if eq $.Values.testRun.benchmark "tproch" }} - # TPC-H Configuration (from merged config) - {{- if eq $target.type "mssql" }} - - name: TPROCH_DATABASE_NAME - value: {{ $target.tproch.databaseName | quote }} - {{- end }} - - name: TPROCH_DRIVER - value: {{ include "hammerdb-scale-test.dbDriver" $target.type | quote }} - - name: TPROCH_SCALE_FACTOR - value: {{ $tprochConfig.scaleFactor | quote }} - - name: TPROCH_BUILD_THREADS - value: {{ $tprochConfig.buildThreads | quote }} - - name: TPROCH_BUILD_VIRTUAL_USERS - value: {{ $tprochConfig.build_num_vu | quote }} - - name: TPROCH_VIRTUAL_USERS - value: {{ $tprochConfig.load_num_vu | quote }} - {{- if eq $target.type "mssql" }} - - name: TPROCH_USE_CLUSTERED_COLUMNSTORE - value: {{ $tprochConfig.useClusteredColumnstore | quote }} - - name: TPROCH_MAXDOP - value: {{ $tprochConfig.maxdop | quote }} - {{- end }} - - name: TPROCH_TOTAL_QUERYSETS - value: {{ $tprochConfig.totalQuerysets | quote }} - - name: TPROCH_LOG_TO_TEMP - value: "1" - {{- end }} - - {{- if $.Values.pureStorage.enabled }} - # Pure Storage metrics collection - - name: PURE_ENABLED - value: "true" - - name: PURE_COLLECT_METRICS - # Only first target (index 0) collects metrics to avoid duplicate API calls - value: {{ if eq $index 0 }}"true"{{ else }}"false"{{ end }} - - name: PURE_HOST - value: {{ $.Values.pureStorage.host | quote }} - - name: PURE_API_TOKEN - value: {{ $.Values.pureStorage.apiToken | quote }} - {{- if $.Values.pureStorage.volume }} - - name: PURE_VOLUME - value: {{ $.Values.pureStorage.volume | quote }} - {{- end }} - - name: PURE_INTERVAL - value: {{ $.Values.pureStorage.pollInterval | quote }} - - name: PURE_NO_VERIFY_SSL - value: {{ ternary "false" "true" $.Values.pureStorage.verifySSL | quote }} - - name: PURE_API_VERSION - value: {{ $.Values.pureStorage.apiVersion | quote }} - - name: PURE_OUTPUT - value: "/tmp/pure_metrics.json" - {{- if $.Values.pureStorage.duration }} - - name: PURE_DURATION - value: {{ $.Values.pureStorage.duration | quote }} - {{- end }} - {{- end }} - - resources: - {{- toYaml $.Values.global.resources | nindent 10 }} - - volumeMounts: - - name: scripts - mountPath: /opt/HammerDB-5.0/scripts - - volumes: - - name: scripts - configMap: - name: {{ include "hammerdb-scale-test.fullname" $ }}-scripts-{{ $target.type }} - defaultMode: 0755 # Make scripts executable -{{- end }} diff --git a/tests/test_chart_security.py b/tests/test_chart_security.py new file mode 100644 index 0000000..96c0c29 --- /dev/null +++ b/tests/test_chart_security.py @@ -0,0 +1,133 @@ +"""Tests for credential handling and pod security in the rendered chart. + +These render the real chart with `helm template` and assert on the output, so +they catch template regressions that unit tests on the values dict cannot. +""" + +from __future__ import annotations + +import shutil +import subprocess +from pathlib import Path + +import pytest +import yaml + +from hammerdb_scale.config.loader import load_config +from hammerdb_scale.helm.values import generate_helm_values + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART = REPO_ROOT / "src/hammerdb_scale/chart" + +PASSWORD = "sup3rs3cret" + +CONFIG = f""" +name: sectest +default_benchmark: tprocc +targets: + defaults: + type: oracle + username: system + password: "{PASSWORD}" + oracle: + service: TPCC + tprocc: + user: TPCC + password: "{PASSWORD}" + hosts: + - name: t1 + host: "10.0.0.1" + - name: t2 + host: "10.0.0.2" +""" + +pytestmark = pytest.mark.skipif( + shutil.which("helm") is None, reason="helm not installed" +) + + +def _render(tmp_path, **overrides) -> list[dict]: + """Render the chart and return the parsed manifests.""" + config_file = tmp_path / "config.yaml" + config_file.write_text(CONFIG) + config = load_config(config_file) + for key, value in overrides.items(): + setattr(config.kubernetes, key, value) + + values = generate_helm_values(config, "run", "tprocc", "t1") + values_file = tmp_path / "values.yaml" + values_file.write_text(yaml.dump(values, sort_keys=False)) + + result = subprocess.run( + ["helm", "template", "t", str(CHART), "-f", str(values_file)], + capture_output=True, + text=True, + timeout=120, + ) + assert result.returncode == 0, result.stderr + return [d for d in yaml.safe_load_all(result.stdout) if d] + + +def _job(docs: list[dict]) -> dict: + return sorted( + (d for d in docs if d["kind"] == "Job"), key=lambda d: d["metadata"]["name"] + )[0] + + +class TestCredentialsUseSecrets: + def test_no_password_anywhere_in_job_spec(self, tmp_path): + """`kubectl describe job` must not reveal database credentials.""" + docs = _render(tmp_path) + for job in (d for d in docs if d["kind"] == "Job"): + assert PASSWORD not in yaml.dump(job) + + def test_credentials_come_from_secret_refs(self, tmp_path): + job = _job(_render(tmp_path)) + env = job["spec"]["template"]["spec"]["containers"][0]["env"] + by_ref = {e["name"] for e in env if "valueFrom" in e} + assert {"USERNAME", "PASSWORD", "TPROCC_PASSWORD"} <= by_ref + + def test_secret_holds_every_target(self, tmp_path): + docs = _render(tmp_path) + secrets = [d for d in docs if d["kind"] == "Secret"] + assert len(secrets) == 1 + keys = secrets[0]["stringData"] + assert keys["target-0-password"] == PASSWORD + assert keys["target-1-password"] == PASSWORD + + def test_can_be_disabled(self, tmp_path): + """Opting out restores inline values, for restricted RBAC setups.""" + docs = _render(tmp_path, use_secrets=False) + assert not [d for d in docs if d["kind"] == "Secret"] + job = _job(docs) + env = job["spec"]["template"]["spec"]["containers"][0]["env"] + inline = {e["name"]: e.get("value") for e in env if "value" in e} + assert inline["PASSWORD"] == PASSWORD + + +class TestPodSecurity: + def test_satisfies_restricted_standard(self, tmp_path): + """Required for plain Kubernetes enforcing restricted PodSecurity.""" + spec = _job(_render(tmp_path))["spec"]["template"]["spec"] + assert spec["securityContext"]["runAsNonRoot"] is True + assert spec["securityContext"]["seccompProfile"]["type"] == "RuntimeDefault" + + container = spec["containers"][0]["securityContext"] + assert container["allowPrivilegeEscalation"] is False + assert container["capabilities"]["drop"] == ["ALL"] + + def test_does_not_pin_runasuser(self, tmp_path): + """OpenShift's SCC assigns a UID from the namespace range. + + Hardcoding one conflicts with that assignment, so the chart must leave + runAsUser unset and let the platform choose. + """ + spec = _job(_render(tmp_path))["spec"]["template"]["spec"] + assert "runAsUser" not in spec.get("securityContext", {}) + assert "runAsUser" not in spec["containers"][0].get("securityContext", {}) + + def test_can_be_disabled(self, tmp_path): + spec = _job(_render(tmp_path, security_context=False))["spec"]["template"][ + "spec" + ] + assert "securityContext" not in spec diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..e9cbb07 --- /dev/null +++ b/tests/test_cli.py @@ -0,0 +1,222 @@ +"""Command-level tests driving the Typer app with the backend mocked. + +The orchestration layer had no tests, and that is exactly where the bugs have +been: the two most recent upstream fixes ("test ID resolution filtering by +deployment name", "YAML scientific notation in run hash generation") were both +here, as were the phase-label and boolean-default bugs found during this work. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest +from typer.testing import CliRunner + +from hammerdb_scale.cli import app +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + WorkloadRef, +) + +runner = CliRunner() + +CONFIG = """ +name: clitest +default_benchmark: tprocc +backend: podman +targets: + defaults: + type: oracle + username: system + password: "pw" + oracle: + service: TPCC + hosts: + - name: t1 + host: "10.0.0.1" +hammerdb: + tprocc: + warehouses: 10 + load_virtual_users: 2 + rampup: 0 + duration: 1 +""" + + +@pytest.fixture +def config_file(tmp_path): + path = tmp_path / "hammerdb-scale.yaml" + path.write_text(CONFIG) + return path + + +@pytest.fixture +def fake_backend(): + """A backend that deploys cleanly and reports one completed workload.""" + backend = MagicMock() + backend.name = "podman" + backend.preflight.return_value = [] + backend.deploy.return_value = ["hdb-load-00-clitest-1"] + backend.list_workloads.return_value = [ + WorkloadRef( + name="hdb-load-00-clitest-1", + target_name="t1", + target_host="10.0.0.1", + database_type="oracle", + index=0, + status=STATUS_COMPLETED, + duration_seconds=65, + ) + ] + backend.get_logs.return_value = ( + "TEST RESULT : System achieved 9108 NOPM from 18882 Oracle TPM" + ) + backend.find_test_ids.return_value = ["clitest-1"] + return backend + + +class TestVersion: + def test_reports_a_version(self): + result = runner.invoke(app, ["version"]) + assert result.exit_code == 0 + assert "hammerdb-scale" in result.stdout + + def test_does_not_use_removed_kubectl_flag(self): + """kubectl dropped --short in 1.28, so it must not be the only attempt.""" + result = runner.invoke(app, ["version"]) + assert "unknown flag" not in result.stdout + + +class TestRun: + def test_deploys_via_the_configured_backend(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run"]) + assert result.exit_code == 0, result.stdout + assert fake_backend.deploy.called + assert "podman" in result.stdout + + def test_passes_run_phase_to_the_backend(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + runner.invoke(app, ["-f", str(config_file), "run"]) + assert fake_backend.deploy.call_args.kwargs["phase"] == "run" + + def test_aborts_when_preflight_fails(self, config_file, fake_backend): + fake_backend.preflight.return_value = ["no container runtime found"] + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run"]) + assert result.exit_code == 1 + assert "no container runtime" in result.stdout + assert not fake_backend.deploy.called + + def test_wait_reports_failure_and_exits_nonzero(self, config_file, fake_backend): + """A failed benchmark must not look like success to a CI pipeline.""" + fake_backend.list_workloads.return_value = [ + WorkloadRef( + name="hdb-load-00-x", + target_name="t1", + target_host="10.0.0.1", + database_type="oracle", + index=0, + status=STATUS_FAILED, + duration_seconds=5, + ) + ] + fake_backend.get_logs.return_value = "ORA-01017: invalid username/password" + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run", "--wait"]) + assert result.exit_code == 1 + + def test_wait_surfaces_the_database_error(self, config_file, fake_backend): + """The reason should be inline, not something to go hunting for.""" + fake_backend.list_workloads.return_value = [ + WorkloadRef( + name="hdb-load-00-x", + target_name="t1", + target_host="10.0.0.1", + database_type="oracle", + index=0, + status=STATUS_FAILED, + duration_seconds=5, + ) + ] + fake_backend.get_logs.return_value = "ORA-01017: invalid username/password" + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "run", "--wait"]) + assert "ORA-01017" in result.stdout + + +class TestStatus: + def test_shows_parsed_metrics_for_completed_work(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "status"]) + assert result.exit_code == 0, result.stdout + assert "18,882" in result.stdout + + def test_json_output_is_machine_readable(self, config_file, fake_backend): + import json + + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "status", "--json"]) + assert result.exit_code == 0 + data = json.loads(result.stdout) + assert data[0]["target"] == "t1" + assert data[0]["status"] == STATUS_COMPLETED + + def test_errors_when_no_workloads_exist(self, config_file, fake_backend): + fake_backend.list_workloads.return_value = [] + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "status"]) + assert result.exit_code == 1 + + +class TestLogs: + def test_prefixes_each_line_with_its_target(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke(app, ["-f", str(config_file), "logs"]) + assert result.exit_code == 0 + assert "t1" in result.stdout + + def test_unknown_target_is_an_error(self, config_file, fake_backend): + with patch("hammerdb_scale.runtime.get_backend", return_value=fake_backend): + result = runner.invoke( + app, ["-f", str(config_file), "logs", "--target", "nope"] + ) + assert result.exit_code == 1 + + +class TestClean: + def test_requires_a_scope_flag(self, config_file): + result = runner.invoke(app, ["-f", str(config_file), "clean"]) + assert result.exit_code == 1 + assert "scope flag" in result.stdout + + def test_database_scope_requires_a_benchmark(self, config_file): + """Dropping the wrong benchmark's tables is destructive and silent.""" + result = runner.invoke(app, ["-f", str(config_file), "clean", "--database"]) + assert result.exit_code == 1 + assert "--benchmark" in result.stdout + + def test_rejects_an_invalid_benchmark(self, config_file): + result = runner.invoke( + app, + ["-f", str(config_file), "clean", "--database", "--benchmark", "tpcx"], + ) + assert result.exit_code == 1 + + def test_resources_scope_requires_id_or_everything(self, config_file): + result = runner.invoke(app, ["-f", str(config_file), "clean", "--resources"]) + assert result.exit_code == 1 + + +class TestConfigDiscovery: + def test_missing_config_is_a_clear_error(self, tmp_path): + result = runner.invoke(app, ["-f", str(tmp_path / "nope.yaml"), "run"]) + assert result.exit_code != 0 + + def test_invalid_yaml_is_reported(self, tmp_path): + bad = tmp_path / "bad.yaml" + bad.write_text("name: [unclosed\n") + result = runner.invoke(app, ["-f", str(bad), "validate"]) + assert result.exit_code == 1 diff --git a/tests/test_container_backend.py b/tests/test_container_backend.py new file mode 100644 index 0000000..0b7a170 --- /dev/null +++ b/tests/test_container_backend.py @@ -0,0 +1,281 @@ +"""Tests for the container backend's runtime-neutral parsing logic. + +podman and docker differ in how they render `ps --format json`, timestamps and +container state. These tests pin down the normalisation so a change in either +runtime's output surfaces here rather than as a wrong benchmark report. +""" + +from __future__ import annotations + +import json + +import pytest + +from hammerdb_scale.runtime.base import ( + STATUS_COMPLETED, + STATUS_FAILED, + STATUS_RUNNING, +) +from hammerdb_scale.runtime.container import ( + _created_sort_key, + LABEL_DB_TYPE, + LABEL_INDEX, + LABEL_TARGET, + LABEL_TARGET_HOST, + ContainerBackend, + _parse_ts, + k8s_cpu_to_cores, + k8s_memory_to_bytes, +) + + +@pytest.fixture +def backend(monkeypatch): + """A backend with runtime detection stubbed out.""" + monkeypatch.setattr( + "hammerdb_scale.runtime.container.detect_runtime", + lambda preferred=None: "podman", + ) + return ContainerBackend() + + +class TestTimestampParsing: + def test_podman_native_go_format(self): + """podman renders Go's native time, not RFC3339.""" + parsed = _parse_ts("2026-07-24 23:32:08.696753997 -0600 MDT") + assert parsed is not None + assert parsed.year == 2026 + assert parsed.hour == 23 + + def test_rfc3339_with_offset(self): + parsed = _parse_ts("2026-07-24T23:32:08.696753997-06:00") + assert parsed is not None + assert parsed.minute == 32 + + def test_rfc3339_zulu(self): + parsed = _parse_ts("2026-07-24T05:21:54.123456789Z") + assert parsed is not None + assert parsed.hour == 5 + + def test_zero_value_means_not_finished(self): + """Both runtimes use year 1 for a container that has not exited.""" + assert _parse_ts("0001-01-01T00:00:00Z") is None + + def test_empty_and_garbage(self): + assert _parse_ts("") is None + assert _parse_ts("not a timestamp") is None + + +class TestPsJsonParsing: + def test_podman_emits_json_array(self): + stdout = json.dumps([{"Names": ["a"]}, {"Names": ["b"]}]) + assert len(ContainerBackend._parse_ps_json(stdout)) == 2 + + def test_docker_emits_newline_delimited_objects(self): + stdout = '{"Names":"a"}\n{"Names":"b"}\n' + assert len(ContainerBackend._parse_ps_json(stdout)) == 2 + + def test_single_object(self): + assert len(ContainerBackend._parse_ps_json('{"Names":"a"}')) == 1 + + def test_empty_and_malformed(self): + assert ContainerBackend._parse_ps_json("") == [] + assert ContainerBackend._parse_ps_json("not json") == [] + + def test_skips_malformed_lines_but_keeps_valid_ones(self): + stdout = '{"Names":"a"}\ngarbage\n{"Names":"b"}' + assert len(ContainerBackend._parse_ps_json(stdout)) == 2 + + +class TestStatusNormalisation: + def test_running(self): + assert ( + ContainerBackend._normalise_status({"State": "running"}) == STATUS_RUNNING + ) + + def test_exited_zero_is_completed(self): + entry = {"State": "exited", "ExitCode": 0} + assert ContainerBackend._normalise_status(entry) == STATUS_COMPLETED + + def test_exited_nonzero_is_failed(self): + entry = {"State": "exited", "ExitCode": 1} + assert ContainerBackend._normalise_status(entry) == STATUS_FAILED + + def test_docker_status_string_fallback(self): + """docker may omit ExitCode and encode it in the status text.""" + assert ( + ContainerBackend._normalise_status( + {"State": "exited", "Status": "Exited (0) 2 minutes ago"} + ) + == STATUS_COMPLETED + ) + assert ( + ContainerBackend._normalise_status( + {"State": "exited", "Status": "Exited (137) 1 minute ago"} + ) + == STATUS_FAILED + ) + + def test_created_counts_as_running(self): + """A created-but-not-started container has not failed.""" + assert ( + ContainerBackend._normalise_status({"State": "created"}) == STATUS_RUNNING + ) + + +class TestWorkloadMapping: + def _entry(self, **labels): + base = { + LABEL_TARGET: "ora-01", + LABEL_TARGET_HOST: "10.0.0.1", + LABEL_DB_TYPE: "oracle", + LABEL_INDEX: "3", + } + base.update(labels) + return { + "Names": ["hdb-run-03-t1"], + "State": "exited", + "ExitCode": 0, + "Labels": base, + } + + def test_maps_labels_onto_workload(self, backend, monkeypatch): + monkeypatch.setattr(backend, "_duration", lambda name: 42) + workload = backend._to_workload(self._entry()) + assert workload.name == "hdb-run-03-t1" + assert workload.target_name == "ora-01" + assert workload.target_host == "10.0.0.1" + assert workload.database_type == "oracle" + assert workload.index == 3 + assert workload.status == STATUS_COMPLETED + assert workload.duration_seconds == 42 + + def test_docker_string_labels(self, backend, monkeypatch): + """docker may return Labels as a comma-separated string.""" + monkeypatch.setattr(backend, "_duration", lambda name: None) + entry = { + "Names": "hdb-run-01-t1", + "State": "running", + "Labels": f"{LABEL_TARGET}=sql-02,{LABEL_INDEX}=1", + } + workload = backend._to_workload(entry) + assert workload.target_name == "sql-02" + assert workload.index == 1 + + def test_missing_labels_do_not_crash(self, backend, monkeypatch): + monkeypatch.setattr(backend, "_duration", lambda name: None) + workload = backend._to_workload({"Names": ["x"], "State": "running"}) + assert workload.target_name == "unknown" + assert workload.index == 0 + + def test_non_numeric_index_defaults_to_zero(self, backend, monkeypatch): + monkeypatch.setattr(backend, "_duration", lambda name: None) + workload = backend._to_workload(self._entry(**{LABEL_INDEX: "abc"})) + assert workload.index == 0 + + +class TestPhaseNormalisation: + """The CLI says "run"; Helm, the entrypoint and the labels all say "load". + + Regression: containers were labelled phase=run while callers filtered on + phase=load, so --wait never saw them finish and timed out. + """ + + def test_run_becomes_load(self): + assert ContainerBackend._normalise_phase("run") == "load" + + def test_build_is_unchanged(self): + assert ContainerBackend._normalise_phase("build") == "build" + + def test_load_is_idempotent(self): + assert ContainerBackend._normalise_phase("load") == "load" + + +class TestNaming: + def test_matches_kubernetes_job_naming(self): + """Names must line up with the Helm chart's job names. + + Helm renders hdb--- with phase already mapped to + "load", so the container names must map it too. + """ + assert ContainerBackend._container_name("run", 0, "abc") == "hdb-load-00-abc" + assert ( + ContainerBackend._container_name("build", 11, "abc") == "hdb-build-11-abc" + ) + + +class TestCreatedSortKey: + """Test-ID ordering decides which run `results` and `report` act on. + + Regression: podman does not guarantee ps ordering, so a stale build run + surfaced ahead of the run just finished and results showed no metrics. + """ + + def test_podman_unix_timestamp(self): + assert _created_sort_key({"Created": 1769000000}) == 1769000000.0 + + def test_docker_rfc3339_string(self): + key = _created_sort_key({"Created": "2026-07-25T13:46:00Z"}) + assert key > 0 + + def test_numeric_string(self): + assert _created_sort_key({"Created": "1769000000"}) == 1769000000.0 + + def test_missing_or_unparseable_sorts_oldest(self): + assert _created_sort_key({}) == float("-inf") + assert _created_sort_key({"Created": "nonsense"}) == float("-inf") + + def test_newer_sorts_higher(self): + older = _created_sort_key({"Created": 1769000000}) + newer = _created_sort_key({"Created": 1769009999}) + assert newer > older + + +class TestResourceTranslation: + """The shared `resources` block is written in Kubernetes units. + + It used to be read only by the Helm path, so setting a limit and running + on containers silently did nothing: the user believed the driver was + capped when it was not. + """ + + @pytest.mark.parametrize( + "value,expected", + [ + ("8Gi", 8 * 1024**3), + ("512Mi", 512 * 1024**2), + ("1G", 1000**3), + ("1024", 1024), + ("", None), + ("garbage", None), + ], + ) + def test_memory_conversion(self, value, expected): + assert k8s_memory_to_bytes(value) == expected + + @pytest.mark.parametrize( + "value,expected", + [("8", 8.0), ("500m", 0.5), ("0.5", 0.5), ("", None), ("bad", None)], + ) + def test_cpu_conversion(self, value, expected): + assert k8s_cpu_to_cores(value) == expected + + def test_limits_become_runtime_flags(self, backend): + backend.memory_limit = "8Gi" + backend.cpu_limit = "4" + args = backend._resource_args() + assert "--memory" in args + assert str(8 * 1024**3) in args + assert "--cpus" in args + assert "4" in args + + def test_unset_limits_produce_no_flags(self, backend): + backend.memory_limit = None + backend.cpu_limit = None + assert backend._resource_args() == [] + + def test_unparseable_limit_degrades_to_no_limit(self, backend): + """An odd quantity must not abort the run.""" + backend.memory_limit = "not-a-size" + backend.cpu_limit = "nonsense" + assert backend._resource_args() == [] diff --git a/tests/test_environment.py b/tests/test_environment.py new file mode 100644 index 0000000..a820667 --- /dev/null +++ b/tests/test_environment.py @@ -0,0 +1,274 @@ +"""Tests for the shared container environment builder. + +These lock down the env-var contract that both backends depend on. If the +Kubernetes backend and the container backend disagree about a variable, the two +execution paths silently produce different benchmarks. +""" + +from __future__ import annotations + +import pytest + +from hammerdb_scale.config.defaults import expand_targets +from hammerdb_scale.config.schema import ( + HammerDBScaleConfig, + HammerDBConfig, + MssqlConfig, + MssqlTproccConfig, + OracleConfig, + OracleTproccConfig, + PureStorageConfig, + StorageMetricsConfig, + TargetDefaults, + TargetHost, + TargetsConfig, + TproccConfig, + TprochConfig, +) +from hammerdb_scale.runtime.environment import build_target_env, get_db_driver + + +def _oracle_config(**overrides) -> HammerDBScaleConfig: + base = dict( + name="ora", + targets=TargetsConfig( + defaults=TargetDefaults( + type="oracle", + username="system", + password="secret", + oracle=OracleConfig( + service="TPCC", + port=1521, + tprocc=OracleTproccConfig(user="TPCC", password="schemapw"), + ), + ), + hosts=[TargetHost(name="ora-01", host="10.0.0.1")], + ), + ) + base.update(overrides) + return HammerDBScaleConfig(**base) + + +def _mssql_config(**overrides) -> HammerDBScaleConfig: + base = dict( + name="sql", + targets=TargetsConfig( + defaults=TargetDefaults( + type="mssql", + username="sa", + password="secret", + mssql=MssqlConfig( + port=1433, + tprocc=MssqlTproccConfig(database_name="TPCC", use_bcp=True), + ), + ), + hosts=[TargetHost(name="sql-01", host="10.0.0.2")], + ), + ) + base.update(overrides) + return HammerDBScaleConfig(**base) + + +def _env_for(config, benchmark="tprocc", phase="run", index=0): + target = expand_targets(config)[index] + return build_target_env( + config, + target, + phase=phase, + benchmark=benchmark, + test_id="t1", + target_index=index, + ) + + +class TestDriverMapping: + def test_known_drivers(self): + assert get_db_driver("mssql") == "mssqls" + assert get_db_driver("oracle") == "oracle" + assert get_db_driver("postgres") == "pg" + + def test_unknown_driver_raises(self): + with pytest.raises(ValueError, match="Unsupported database type"): + get_db_driver("cassandra") + + +class TestPhaseMapping: + def test_run_phase_becomes_load(self): + """The CLI says "run"; entrypoint.sh expects "load".""" + assert _env_for(_oracle_config(), phase="run")["RUN_MODE"] == "load" + + def test_build_phase_unchanged(self): + assert _env_for(_oracle_config(), phase="build")["RUN_MODE"] == "build" + + +class TestOracleEnv: + def test_connection_vars(self): + env = _env_for(_oracle_config()) + assert env["HOST"] == "10.0.0.1" + assert env["ORACLE_SERVICE"] == "TPCC" + assert env["ORACLE_PORT"] == "1521" + assert env["DATABASE_TYPE"] == "oracle" + assert env["TPROCC_DRIVER"] == "oracle" + + def test_schema_credentials(self): + env = _env_for(_oracle_config()) + assert env["TPROCC_USER"] == "TPCC" + assert env["TPROCC_PASSWORD"] == "schemapw" + + def test_schema_password_falls_back_to_target_password(self): + """An unset schema password inherits the target's password.""" + config = _oracle_config() + config.targets.defaults.oracle.tprocc.password = "" + assert _env_for(config)["TPROCC_PASSWORD"] == "secret" + + def test_no_mssql_vars_leak_in(self): + env = _env_for(_oracle_config()) + assert "MSSQLS_PORT" not in env + assert "SQL_SERVER_HOST" not in env + + +class TestMssqlEnv: + def test_connection_vars(self): + env = _env_for(_mssql_config()) + assert env["SQL_SERVER_HOST"] == "10.0.0.2" + assert env["MSSQLS_PORT"] == "1433" + assert env["TPROCC_DRIVER"] == "mssqls" + assert env["TPROCC_DATABASE_NAME"] == "TPCC" + + def test_booleans_render_lowercase(self): + """TCL compares against the literal strings "true"/"false".""" + env = _env_for(_mssql_config()) + assert env["MSSQLS_ENCRYPT_CONNECTION"] == "true" + assert env["USE_BCP"] == "true" + + def test_no_oracle_vars_leak_in(self): + env = _env_for(_mssql_config()) + assert "ORACLE_SERVICE" not in env + assert "TPROCC_USER" not in env + + +class TestBenchmarkSelection: + def test_tprocc_excludes_tproch_vars(self): + env = _env_for(_oracle_config(), benchmark="tprocc") + assert "WAREHOUSES" in env + assert "TPROCH_SCALE_FACTOR" not in env + + def test_tproch_excludes_tprocc_vars(self): + env = _env_for(_oracle_config(), benchmark="tproch") + assert "TPROCH_SCALE_FACTOR" in env + assert "WAREHOUSES" not in env + + def test_large_integers_never_use_scientific_notation(self): + """Regression: Helm rendered 10000000 as "1e+07", which TCL rejects.""" + config = _oracle_config( + hammerdb=HammerDBConfig(tprocc=TproccConfig(total_iterations=10_000_000)) + ) + value = _env_for(config)["TOTAL_ITERATIONS"] + assert value == "10000000" + assert "e" not in value.lower() + + def test_all_values_are_strings(self): + """Container runtimes and K8s env both require string values.""" + for benchmark in ("tprocc", "tproch"): + env = _env_for(_oracle_config(), benchmark=benchmark) + assert all(isinstance(v, str) for v in env.values()) + + +class TestPureStorageEnv: + def _config(self): + return _oracle_config( + storage_metrics=StorageMetricsConfig( + enabled=True, + pure=PureStorageConfig(host="10.1.1.1", api_token="tok"), + ) + ) + + def test_disabled_by_default(self): + assert "PURE_ENABLED" not in _env_for(_oracle_config()) + + def test_only_first_target_collects(self): + """N targets must not each poll the array's REST API.""" + config = self._config() + config.targets.hosts.append(TargetHost(name="ora-02", host="10.0.0.9")) + assert _env_for(config, index=0)["PURE_COLLECT_METRICS"] == "true" + assert _env_for(config, index=1)["PURE_COLLECT_METRICS"] == "false" + + def test_verify_ssl_is_inverted_for_the_collector(self): + """The collector takes PURE_NO_VERIFY_SSL, the inverse of verify_ssl.""" + config = self._config() + config.storage_metrics.pure.verify_ssl = False + assert _env_for(config)["PURE_NO_VERIFY_SSL"] == "true" + + +class TestTprochEnv: + def test_mssql_only_vars_are_gated(self): + oracle_env = _env_for(_oracle_config(), benchmark="tproch") + assert "TPROCH_MAXDOP" not in oracle_env + + mssql_env = _env_for(_mssql_config(), benchmark="tproch") + assert mssql_env["TPROCH_MAXDOP"] == "2" + + def test_scale_factor_propagates(self): + config = _oracle_config( + hammerdb=HammerDBConfig(tproch=TprochConfig(scale_factor=100)) + ) + assert _env_for(config, benchmark="tproch")["TPROCH_SCALE_FACTOR"] == "100" + + +def _postgres_config(**overrides) -> HammerDBScaleConfig: + from hammerdb_scale.config.schema import PostgresConfig, PostgresTproccConfig + + base = dict( + name="pg", + targets=TargetsConfig( + defaults=TargetDefaults( + type="postgres", + username="postgres", + password="secret", + postgres=PostgresConfig( + port=5432, + tprocc=PostgresTproccConfig( + database_name="tpcc", user="tpcc", password="tpccpw" + ), + ), + ), + hosts=[TargetHost(name="pg-01", host="10.0.0.3")], + ), + ) + base.update(overrides) + return HammerDBScaleConfig(**base) + + +class TestPostgresEnv: + def test_connection_vars(self): + env = _env_for(_postgres_config()) + assert env["HOST"] == "10.0.0.3" + assert env["PG_PORT"] == "5432" + assert env["DATABASE_TYPE"] == "postgres" + assert env["TPROCC_DRIVER"] == "pg" + + def test_schema_credentials(self): + env = _env_for(_postgres_config()) + assert env["TPROCC_USER"] == "tpcc" + assert env["TPROCC_PASSWORD"] == "tpccpw" + assert env["TPROCC_DATABASE_NAME"] == "tpcc" + + def test_stored_procedures_default_on(self): + """HammerDB's recommended default for PostgreSQL TPROC-C.""" + assert _env_for(_postgres_config())["PG_STOREDPROCS"] == "true" + + def test_stored_procedures_can_be_disabled(self): + config = _postgres_config() + config.targets.defaults.postgres.tprocc.stored_procedures = False + assert _env_for(config)["PG_STOREDPROCS"] == "false" + + def test_no_other_engine_vars_leak_in(self): + env = _env_for(_postgres_config()) + assert "ORACLE_SERVICE" not in env + assert "MSSQLS_PORT" not in env + + def test_tproch_uses_tproch_credentials(self): + env = _env_for(_postgres_config(), benchmark="tproch") + assert env["TPROCH_USER"] == "tpch" + assert env["TPROCH_DATABASE_NAME"] == "tpch" + assert "PG_STOREDPROCS" not in env diff --git a/tests/test_examples_and_wizard.py b/tests/test_examples_and_wizard.py new file mode 100644 index 0000000..56e3aa9 --- /dev/null +++ b/tests/test_examples_and_wizard.py @@ -0,0 +1,129 @@ +"""Tests for shipped example configs and the init wizard's generated YAML. + +Examples are the entry path for anyone who would rather read a file than +answer prompts, so a stale example is a support burden. These tests fail if +one stops loading or drifts from the documented backend guidance. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest + +from hammerdb_scale.cli import _build_config_yaml +from hammerdb_scale.config.defaults import expand_targets +from hammerdb_scale.config.loader import load_config + +REPO_ROOT = Path(__file__).resolve().parent.parent +EXAMPLES = sorted((REPO_ROOT / "examples").glob("*.yaml")) + +ORACLE_BLOCK = { + "service": "ORCLPDB", + "port": 1521, + "tablespace": "TPCC", + "temp_tablespace": "TEMP", + "tprocc": {"user": "TPCC", "password": "p"}, + "tproch": {"user": "tpch", "password": "p"}, +} + + +def _load_yaml_string(text: str): + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as handle: + handle.write(text) + path = handle.name + try: + return load_config(Path(path)) + finally: + Path(path).unlink(missing_ok=True) + + +class TestShippedExamples: + @pytest.mark.parametrize("path", EXAMPLES, ids=lambda p: p.name) + def test_example_loads(self, path): + config = load_config(path) + assert expand_targets(config), f"{path.name} expands to no targets" + + def test_container_examples_exist(self): + """The README points people at the container path first.""" + names = {p.name for p in EXAMPLES} + assert "container-mssql-tprocc.yaml" in names + assert "container-postgres-tprocc.yaml" in names + + @pytest.mark.parametrize( + "name", ["container-mssql-tprocc.yaml", "container-postgres-tprocc.yaml"] + ) + def test_container_examples_do_not_need_a_cluster(self, name): + config = load_config(REPO_ROOT / "examples" / name) + assert config.backend.value in ("podman", "docker") + + +class TestWizardOutput: + """init must emit a config that loads, for every backend and database.""" + + @pytest.mark.parametrize("backend", ["podman", "docker", "kubernetes"]) + @pytest.mark.parametrize("db_type", ["mssql", "postgres", "oracle"]) + def test_generated_config_round_trips(self, backend, db_type): + yaml_text = _build_config_yaml( + name="t", + db_type_str=db_type, + benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", + password="p", + oracle_config=ORACLE_BLOCK if db_type == "oracle" else None, + backend_str=backend, + namespace="hammerdb" if backend == "kubernetes" else None, + ) + config = _load_yaml_string(yaml_text) + assert config.backend.value == backend + assert expand_targets(config)[0]["type"] == db_type + + def test_container_config_has_no_kubernetes_block(self): + """A cluster block implies a cluster is needed. It is not.""" + yaml_text = _build_config_yaml( + name="t", + db_type_str="mssql", + benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", + password="p", + oracle_config=None, + backend_str="podman", + namespace=None, + ) + assert "kubernetes:" not in yaml_text + assert "container:" in yaml_text + + def test_kubernetes_config_keeps_its_namespace(self): + yaml_text = _build_config_yaml( + name="t", + db_type_str="mssql", + benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", + password="p", + oracle_config=None, + backend_str="kubernetes", + namespace="my-ns", + ) + assert "kubernetes:" in yaml_text + assert "my-ns" in yaml_text + assert "container:" not in yaml_text + + def test_postgres_does_not_emit_mssql_settings(self): + """Regression: postgres fell into the mssql branch of the template.""" + yaml_text = _build_config_yaml( + name="t", + db_type_str="postgres", + benchmark_str="tprocc", + hosts=[{"name": "a", "host": "10.0.0.1"}], + username="u", + password="p", + oracle_config=None, + backend_str="podman", + namespace=None, + ) + assert "postgres:" in yaml_text + assert "odbc_driver" not in yaml_text diff --git a/tests/test_hammerdb_version.py b/tests/test_hammerdb_version.py new file mode 100644 index 0000000..97878ef --- /dev/null +++ b/tests/test_hammerdb_version.py @@ -0,0 +1,168 @@ +"""Tests that the HammerDB version stays consistent across image and chart. + +The version appears in three coupled places: ARG HAMMERDB_VERSION in the +Dockerfile, the chart's script mountPath, and DEFAULT_HAMMERDB_HOME. If the +image installs to one path and the chart mounts scripts at another, containers +start and then fail to find their TCL scripts, which is a confusing runtime +failure rather than a build error. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +from hammerdb_scale.constants import ( + DEFAULT_HAMMERDB_HOME, + DEFAULT_HAMMERDB_VERSION, + PUBLISHED_HAMMERDB_VERSION, +) + +REPO_ROOT = Path(__file__).resolve().parent.parent +CHART_TEMPLATE = "templates/job-hammerdb-worker.yaml" + + +def _dockerfile_version() -> str: + text = (REPO_ROOT / "Dockerfile").read_text() + match = re.search(r"^ARG HAMMERDB_VERSION=(\S+)", text, re.MULTILINE) + assert match, "Dockerfile must declare ARG HAMMERDB_VERSION" + return match.group(1) + + +class TestVersionConsistency: + def test_dockerfile_matches_constants(self): + assert _dockerfile_version() == DEFAULT_HAMMERDB_VERSION + + def test_home_tracks_the_published_image(self): + """The chart default must point at a path the published image has. + + Regression: the default moved to 6.0 while the published image was + still 5.0, so every out-of-the-box Kubernetes run mounted scripts into + a directory the entrypoint never looked in and failed with + "script not found". + """ + assert DEFAULT_HAMMERDB_HOME == f"/opt/HammerDB-{PUBLISHED_HAMMERDB_VERSION}" + + def test_chart_fallback_matches_constants(self): + """The chart's default mountPath must match where the image installs.""" + for chart_dir in ("", "src/hammerdb_scale/chart/"): + template = (REPO_ROOT / chart_dir / CHART_TEMPLATE).read_text() + match = re.search(r'default "(/opt/HammerDB-[^"]+)"', template) + assert match, f"{chart_dir}{CHART_TEMPLATE} must have a mountPath default" + assert match.group(1) == DEFAULT_HAMMERDB_HOME + + +class TestNoHardcodedPaths: + """A hardcoded version defeats the point of the indirection.""" + + @pytest.mark.parametrize( + "relative_path", + ["entrypoint.sh", "Dockerfile", "Dockerfile.oracle"], + ) + def test_no_versioned_hammerdb_paths(self, relative_path): + text = (REPO_ROOT / relative_path).read_text() + offenders = [ + line.strip() + for line in text.splitlines() + # Comments may legitimately mention a version by way of example. + if "/opt/HammerDB-5" in line and not line.strip().startswith("#") + ] + assert not offenders, f"{relative_path} has hardcoded paths: {offenders}" + + def test_entrypoint_uses_hammerdb_home(self): + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert "HAMMERDB_HOME" in text + assert '"$HAMMERDB_HOME/hammerdbcli"' in text + assert "$SCRIPT_DIR/" in text + + def test_entrypoint_falls_back_when_home_unset(self): + """Older images have no HAMMERDB_HOME, so a glob probe is required.""" + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert "for candidate in /opt/HammerDB-*" in text + + def test_entrypoint_searches_for_mounted_scripts(self): + """The script mount path and HAMMERDB_HOME are set independently. + + The chart chooses the mount path; the image bakes in HAMMERDB_HOME. + Requiring them to agree means a chart/image version skew produces a + confusing "script not found" at runtime, so the entrypoint searches. + """ + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert ( + 'for candidate in "$HAMMERDB_HOME/scripts" /opt/HammerDB-*/scripts' in text + ) + assert "HAMMERDB_SCRIPT_DIR" in text + + def test_oracle_client_check_is_version_agnostic(self): + """A client upgrade must not require editing the entrypoint.""" + text = (REPO_ROOT / "entrypoint.sh").read_text() + assert "instantclient_21_11" not in text + assert "/opt/oracle/instantclient_*" in text + + +class TestChartIsNotDuplicated: + """The repo-root chart paths must be symlinks into the packaged chart. + + They used to be real duplicates, and they had already drifted: + collect_pure_metrics.py differed between the two copies. Because + get_chart_path() prefers the packaged copy, a fix applied only at the repo + root would never reach anyone installing from PyPI. Symlinks make the + packaged chart the single source of truth while keeping `helm ... .` + working from the repo root. + """ + + @pytest.mark.parametrize( + "entry", ["templates", "scripts", "Chart.yaml", "values.yaml"] + ) + def test_repo_root_entry_is_a_symlink(self, entry): + path = REPO_ROOT / entry + assert path.is_symlink(), ( + f"{entry} should be a symlink into src/hammerdb_scale/chart/, " + f"not a second copy that can drift" + ) + assert ( + path.resolve() == (REPO_ROOT / "src/hammerdb_scale/chart" / entry).resolve() + ) + + def test_both_paths_reach_the_same_template(self): + root = (REPO_ROOT / CHART_TEMPLATE).read_text() + packaged = (REPO_ROOT / "src/hammerdb_scale/chart" / CHART_TEMPLATE).read_text() + assert root == packaged + + +class TestReleaseVersions: + """Chart and CLI versions must move together. + + They drifted before: the chart shipped 2.0.2 against a 2.0.3 CLI, so a + `helm list` reported a version that did not match the tool that deployed + it. + """ + + def _chart_version(self) -> str: + text = (REPO_ROOT / "src/hammerdb_scale/chart/Chart.yaml").read_text() + match = re.search(r"^version:\s*(\S+)", text, re.MULTILINE) + assert match, "Chart.yaml must declare a version" + return match.group(1) + + def _pyproject_version(self) -> str: + text = (REPO_ROOT / "pyproject.toml").read_text() + match = re.search(r'^version\s*=\s*"([^"]+)"', text, re.MULTILINE) + assert match, "pyproject.toml must declare a version" + return match.group(1) + + def test_chart_matches_pyproject(self): + assert self._chart_version() == self._pyproject_version() + + def test_cli_reports_pyproject_version(self): + from hammerdb_scale.constants import VERSION + + assert VERSION == self._pyproject_version() + + def test_chart_appversion_tracks_published_hammerdb(self): + """appVersion advertises the HammerDB the published images carry.""" + text = (REPO_ROOT / "src/hammerdb_scale/chart/Chart.yaml").read_text() + match = re.search(r'^appVersion:\s*"?([^"\s]+)"?', text, re.MULTILINE) + assert match, "Chart.yaml must declare an appVersion" + assert match.group(1) == PUBLISHED_HAMMERDB_VERSION diff --git a/tests/test_parsers.py b/tests/test_parsers.py index 74623f1..2dcafa8 100644 --- a/tests/test_parsers.py +++ b/tests/test_parsers.py @@ -198,6 +198,11 @@ def test_oracle_returns_oracle_parser(self): def test_mssql_returns_mssql_parser(self): assert isinstance(get_parser("mssql"), MssqlParser) + def test_postgres_returns_postgres_parser(self): + from hammerdb_scale.results.parsers import PostgresParser + + assert isinstance(get_parser("postgres"), PostgresParser) + def test_unknown_raises(self): with pytest.raises(ValueError, match="No parser"): - get_parser("postgres") + get_parser("mysql") diff --git a/tests/test_schema.py b/tests/test_schema.py index e87c6b6..f45375f 100644 --- a/tests/test_schema.py +++ b/tests/test_schema.py @@ -181,18 +181,35 @@ def test_mssql_with_base_image_no_warning(self): class TestDatabaseTypeValidation: - def test_oracle_type_requires_oracle_block(self): + """An omitted database block means "use the defaults". + + It used to be a hard error, which forced ~15 lines of config that only + restated values the schema already defaults. Every field in OracleConfig + and MssqlConfig has a working default, so requiring it bought nothing. + """ + + def test_oracle_block_is_filled_in_when_omitted(self): data = _minimal_config() del data["targets"]["defaults"]["oracle"] - with pytest.raises(ValidationError, match="targets.defaults.oracle"): - HammerDBScaleConfig(**data) + config = HammerDBScaleConfig(**data) + assert config.targets.defaults.oracle is not None + assert config.targets.defaults.oracle.port == 1521 + assert config.targets.defaults.oracle.service == "ORCLPDB" - def test_mssql_type_requires_mssql_block(self): + def test_mssql_block_is_filled_in_when_omitted(self): data = _minimal_config() data["targets"]["defaults"]["type"] = "mssql" del data["targets"]["defaults"]["oracle"] - with pytest.raises(ValidationError, match="targets.defaults.mssql"): - HammerDBScaleConfig(**data) + config = HammerDBScaleConfig(**data) + assert config.targets.defaults.mssql is not None + assert config.targets.defaults.mssql.port == 1433 + + def test_explicit_block_is_not_overwritten(self): + data = _minimal_config() + data["targets"]["defaults"]["oracle"] = {"service": "CUSTOM", "port": 1600} + config = HammerDBScaleConfig(**data) + assert config.targets.defaults.oracle.service == "CUSTOM" + assert config.targets.defaults.oracle.port == 1600 def test_mssql_type_with_mssql_block_valid(self): data = _minimal_config() diff --git a/tests/test_scoping_and_mounts.py b/tests/test_scoping_and_mounts.py new file mode 100644 index 0000000..ff84eaf --- /dev/null +++ b/tests/test_scoping_and_mounts.py @@ -0,0 +1,134 @@ +"""Tests for run isolation, mount options, and per-database preflight ports. + +These cover three defects that all produced confidently wrong output rather +than an error, which is the failure mode most likely to mislead a user. +""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +import pytest +import yaml + +from hammerdb_scale.config.loader import load_config +from hammerdb_scale.runtime.container import ( + LABEL_DEPLOYMENT, + LABEL_MANAGED, + ContainerBackend, + mount_suffix, + selinux_is_enforcing, +) +from hammerdb_scale.runtime.preflight import _target_endpoints + + +def _config(db_type: str, extra: dict | None = None): + raw = { + "name": "t", + "targets": { + "defaults": { + "type": db_type, + "username": "u", + "password": "p", + **(extra or {}), + }, + "hosts": [{"name": "a", "host": "10.0.0.1"}], + }, + } + with tempfile.NamedTemporaryFile("w", suffix=".yaml", delete=False) as handle: + yaml.safe_dump(raw, handle) + path = handle.name + try: + return load_config(Path(path)) + finally: + Path(path).unlink(missing_ok=True) + + +class TestPreflightPorts: + """Probing the wrong port reports a healthy fleet as unreachable.""" + + @pytest.mark.parametrize( + "db_type,expected", [("oracle", 1521), ("mssql", 1433), ("postgres", 5432)] + ) + def test_default_port_per_database(self, db_type, expected): + assert _target_endpoints(_config(db_type))[0][2] == expected + + def test_postgres_is_not_probed_on_the_mssql_port(self): + """Regression: postgres fell through to the mssql branch.""" + assert _target_endpoints(_config("postgres"))[0][2] != 1433 + + @pytest.mark.parametrize( + "db_type,block,expected", + [ + ("postgres", {"postgres": {"port": 5433}}, 5433), + ("mssql", {"mssql": {"port": 1435}}, 1435), + ("oracle", {"oracle": {"port": 1522}}, 1522), + ], + ) + def test_configured_port_wins(self, db_type, block, expected): + assert _target_endpoints(_config(db_type, block))[0][2] == expected + + +class TestMountSuffix: + """Docker Desktop mishandles the :z relabel, so it must be conditional.""" + + def test_enforcing_host_gets_relabel(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.selinux_is_enforcing", lambda: True + ) + assert mount_suffix() == "ro,z" + + def test_non_selinux_host_gets_plain_readonly(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.selinux_is_enforcing", lambda: False + ) + assert mount_suffix() == "ro" + + def test_detection_survives_missing_sysfs(self, monkeypatch): + """macOS and WSL2 have no /sys/fs/selinux at all.""" + monkeypatch.setattr(Path, "exists", lambda self: False) + assert selinux_is_enforcing() is False + + +class TestDeploymentScoping: + """Two runs on one host must not see each other's containers.""" + + @pytest.fixture + def backend(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.detect_runtime", + lambda preferred=None: "podman", + ) + return ContainerBackend(deployment_name="alpha") + + def test_scope_filters_include_deployment(self, backend): + filters = backend._scope_filters() + assert f"label={LABEL_DEPLOYMENT}=alpha" in filters + assert any(LABEL_MANAGED in f for f in filters) + + def test_unnamed_backend_filters_on_managed_only(self, monkeypatch): + monkeypatch.setattr( + "hammerdb_scale.runtime.container.detect_runtime", + lambda preferred=None: "podman", + ) + filters = ContainerBackend()._scope_filters() + assert not any(LABEL_DEPLOYMENT in f for f in filters) + + def test_remove_is_scoped_to_this_config(self, backend, monkeypatch): + """`clean --all` must not delete a colleague's run.""" + seen = {} + + def fake_run(args, **kwargs): + seen["args"] = args + + class R: + returncode = 0 + stdout = "" + stderr = "" + + return R() + + monkeypatch.setattr(backend, "_run", fake_run) + backend.remove(everything=True) + assert f"label={LABEL_DEPLOYMENT}=alpha" in seen["args"] diff --git a/tests/test_timings.py b/tests/test_timings.py new file mode 100644 index 0000000..506e94c --- /dev/null +++ b/tests/test_timings.py @@ -0,0 +1,197 @@ +"""Tests for HammerDB 6.0 transaction response time capture. + +Time profiling is optional, so every layer must treat missing timing data as +normal rather than as an error: a run with profiling off must still parse, +aggregate and render cleanly. +""" + +from __future__ import annotations + +from hammerdb_scale.reports.generator import ( + _aggregate_timings, + _latency_section_html, + generate_scorecard, +) +from hammerdb_scale.results.parsers import parse_transaction_timings + +PROFILED = """ +TRANSACTION RESPONSE TIMES +{ + "NEWORD": { + "elapsed_ms": "238147.5", "calls": "173647", "min_ms": "1.942", + "avg_ms": "7.083", "max_ms": "133.943", "total_ms": "1229911.98", + "p99_ms": "11.522", "p95_ms": "10.272", "p75_ms": "8.516", + "p50_ms": "6.993", "p25_ms": "5.551", "sd": "1.961", "ratio_pct": "64.556" + }, + "PAYMENT": { + "elapsed_ms": "238147.5", "calls": "174104", "min_ms": "0.782", + "avg_ms": "1.895", "max_ms": "49.163", "total_ms": "329840.155", + "p99_ms": "3.131", "p95_ms": "2.502", "p75_ms": "2.180", + "p50_ms": "1.950", "p25_ms": "1.520", "sd": "0.9", "ratio_pct": "17.31" + } +} + +TRANSACTION COUNT +""" + +# What HammerDB emits when the run had profiling disabled. +UNPROFILED = """ +TRANSACTION RESPONSE TIMES +{"6A653C6E3D5C03E283238343": { + "Jobid": "has", + "no": "timing", + "data": "" + }} + +TRANSACTION COUNT +""" + + +class TestParsing: + def test_extracts_every_transaction(self): + timings = parse_transaction_timings(PROFILED) + assert {t.name for t in timings} == {"NEWORD", "PAYMENT"} + + def test_sorted_by_call_count(self): + timings = parse_transaction_timings(PROFILED) + assert timings[0].name == "PAYMENT" # 174104 > 173647 + + def test_numeric_conversion(self): + neword = next( + t for t in parse_transaction_timings(PROFILED) if t.name == "NEWORD" + ) + assert neword.calls == 173647 + assert neword.p99_ms == 11.522 + assert neword.max_ms == 133.943 + + def test_placeholder_yields_nothing(self): + """Profiling off must look identical to absent, not produce a row.""" + assert parse_transaction_timings(UNPROFILED) == [] + + def test_absent_block(self): + assert parse_transaction_timings("no timing section here") == [] + + def test_malformed_json_does_not_raise(self): + assert parse_transaction_timings("TRANSACTION RESPONSE TIMES\n{broken") == [] + + +class TestAggregation: + def _target(self, calls, p99, max_ms): + return { + "tprocc": { + "timings": [ + { + "name": "NEWORD", + "calls": calls, + "avg_ms": 5.0, + "p50_ms": 4.0, + "p95_ms": p99 - 1, + "p99_ms": p99, + "max_ms": max_ms, + } + ] + } + } + + def test_percentiles_are_call_weighted(self): + rows = _aggregate_timings( + [self._target(100, 10.0, 50.0), self._target(900, 20.0, 60.0)] + ) + # 900 calls at p99=20 must dominate 100 calls at p99=10. + assert rows[0]["p99_ms"] == 19.0 + + def test_max_is_worst_seen_not_averaged(self): + """A single stall on one target is the finding, so it must survive.""" + rows = _aggregate_timings( + [self._target(100, 10.0, 50.0), self._target(100, 10.0, 12638.98)] + ) + assert rows[0]["max_ms"] == 12638.98 + + def test_calls_sum_across_targets(self): + rows = _aggregate_timings( + [self._target(100, 10.0, 1.0), self._target(250, 10.0, 1.0)] + ) + assert rows[0]["calls"] == 350 + + def test_no_timings_yields_no_rows(self): + assert _aggregate_timings([{"tprocc": {"tpm": 1, "nopm": 1}}]) == [] + + def test_zero_call_entries_ignored(self): + assert _aggregate_timings([self._target(0, 10.0, 1.0)]) == [] + + +class TestReportDegradation: + """The section must vanish when there is no data, never render empty.""" + + def _summary(self, timings=None): + target = { + "name": "sql-01", + "host": "10.0.0.1", + "status": "completed", + "duration_seconds": 245, + "tprocc": {"tpm": 98575, "nopm": 42457}, + } + if timings: + target["tprocc"]["timings"] = timings + return { + "test_id": "t1", + "benchmark": "tprocc", + "config": {"database_type": "mssql", "target_count": 1}, + "targets": [target], + "aggregate": { + "total_tpm": 98575, + "total_nopm": 42457, + "targets_completed": 1, + "avg_tpm": 98575, + }, + } + + ROW = [ + { + "name": "NEWORD", + "calls": 1000, + "avg_ms": 7.0, + "p50_ms": 6.9, + "p95_ms": 10.2, + "p99_ms": 11.5, + "max_ms": 133.9, + } + ] + + def test_section_omitted_without_timings(self): + assert _latency_section_html([{"tprocc": {"tpm": 1}}]) == "" + + def test_section_present_with_timings(self): + html = _latency_section_html([{"tprocc": {"timings": self.ROW}}]) + assert "Transaction Response Times" in html + assert "NEWORD" in html + + def test_report_renders_without_timings_or_array(self): + html = generate_scorecard(self._summary(), pure_metrics=None) + assert "Transaction Response Times" not in html + assert "Storage Performance" not in html + + def test_report_renders_with_timings_only(self): + """Latency data must be usable even when no array was polled.""" + html = generate_scorecard(self._summary(self.ROW), pure_metrics=None) + assert "Transaction Response Times" in html + assert "Storage Performance" not in html + + def test_report_renders_with_both(self): + pure = { + "raw_metrics": [ + { + "timestamp": "t", + "read_latency_us": 300, + "write_latency_us": 400, + "read_iops": 10, + "write_iops": 5, + "read_bandwidth_mbps": 1, + "write_bandwidth_mbps": 1, + } + ], + "summary": {}, + } + html = generate_scorecard(self._summary(self.ROW), pure_metrics=pure) + assert "Transaction Response Times" in html + assert "Storage Performance" in html diff --git a/tests/test_wizard.py b/tests/test_wizard.py index b7102cb..80d4452 100644 --- a/tests/test_wizard.py +++ b/tests/test_wizard.py @@ -144,7 +144,8 @@ def test_oracle_tprocc_basic(self, mock_prompt, mock_int, mock_confirm) -> None: """Full wizard flow for Oracle TPC-C without advanced options.""" mock_prompt.ask.side_effect = [ "my-bench", # deployment name - "1", # oracle + "3", # kubernetes backend + "3", # oracle "1", # tprocc "ora-01", # target name "10.0.0.1", # target host @@ -182,7 +183,8 @@ def test_mssql_tproch_basic(self, mock_prompt, mock_int, mock_confirm) -> None: """Full wizard flow for MSSQL TPC-H.""" mock_prompt.ask.side_effect = [ "sql-test", # deployment name - "2", # mssql + "3", # kubernetes backend + "1", # mssql "2", # tproch "sql-01", # target name "10.0.0.5", # target host @@ -217,8 +219,9 @@ def test_user_cancels_at_confirmation( """Wizard returns None when user declines at the write confirmation.""" mock_prompt.ask.side_effect = [ "test", - "2", - "1", + "3", # kubernetes backend + "1", # mssql + "1", # tprocc "db-01", "10.0.0.1", "sa", @@ -242,8 +245,9 @@ def test_with_advanced_options(self, mock_prompt, mock_int, mock_confirm) -> Non """Wizard with advanced options for TPC-C.""" mock_prompt.ask.side_effect = [ "adv-bench", - "2", - "1", # name, mssql, tprocc + "3", # kubernetes backend + "1", + "1", # mssql, tprocc "db-01", "10.0.0.1", # target "sa", @@ -345,3 +349,62 @@ def test_default_values_match_original(self) -> None: assert "duration: 10" in yaml_str assert 'memory: "4Gi"' in yaml_str assert 'cpu: "4"' in yaml_str + + +class TestBackendSelection: + """The wizard must not interrogate a container user about a cluster. + + Asking for a Kubernetes namespace when the workers run locally is what + made the tool look like it required a cluster when it does not. + """ + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_container_backend_skips_namespace( + self, mock_prompt, mock_int, mock_confirm + ) -> None: + mock_prompt.ask.side_effect = [ + "ctr-bench", + "1", # first container runtime offered + "1", # mssql + "1", # tprocc + "db-01", + "10.0.0.1", + "sa", + "pass", + # No namespace answer supplied: asking for one would raise + # StopIteration and fail this test, which is the point. + ] + mock_int.ask.side_effect = [1, 100] + mock_confirm.ask.side_effect = [False, False, True] + + result = run_wizard() + assert result is not None + assert result["backend_str"] in ("podman", "docker") + assert result["namespace"] is None + + @patch("hammerdb_scale.wizard.Confirm") + @patch("hammerdb_scale.wizard.IntPrompt") + @patch("hammerdb_scale.wizard.Prompt") + def test_kubernetes_backend_still_asks_for_namespace( + self, mock_prompt, mock_int, mock_confirm + ) -> None: + mock_prompt.ask.side_effect = [ + "k8s-bench", + "3", # kubernetes is always last + "1", # mssql + "1", # tprocc + "db-01", + "10.0.0.1", + "sa", + "pass", + "my-namespace", + ] + mock_int.ask.side_effect = [1, 100] + mock_confirm.ask.side_effect = [False, False, True] + + result = run_wizard() + assert result is not None + assert result["backend_str"] == "kubernetes" + assert result["namespace"] == "my-namespace" diff --git a/values.yaml b/values.yaml deleted file mode 100644 index 04dc5f3..0000000 --- a/values.yaml +++ /dev/null @@ -1,109 +0,0 @@ -# ============================================================================ -# HammerDB-Scale Configuration -# ============================================================================ -# Docs: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/CONFIGURATION.md -# Guide: https://github.com/PureStorage-OpenConnect/hammerdb-scale/blob/main/docs/USAGE-GUIDE.md -# -# Quick start: -# 1. Review and edit the values below (especially targets, warehouses, and virtual users) -# 2. hammerdb-scale validate # check config, tools, and database connectivity -# 3. hammerdb-scale run --build --wait # build schemas and run the benchmark -# 4. hammerdb-scale results # aggregate results -# 5. hammerdb-scale report --open # generate HTML scorecard -# ============================================================================ - -name: my-benchmark -description: "MSSQL TPROCC benchmark — 2 targets" -default_benchmark: tprocc - -# ============================================================================ -# DATABASE TARGETS -# ============================================================================ -# "defaults" are inherited by all hosts — override any field per-host if needed. -# Add or remove hosts to match your environment. -targets: - defaults: - type: mssql - username: sa - password: "YourPassword" - image: - repository: sillidata/hammerdb-scale - tag: latest - pull_policy: Always # Always | IfNotPresent | Never - mssql: - port: 1433 - tprocc: - database_name: tpcc # database created during build phase - tproch: - database_name: tpch # database created during build phase - - hosts: - - name: sql-01 - host: "sql-01.example.com" - - name: sql-02 - host: "sql-02.example.com" - -# ============================================================================ -# BENCHMARK PARAMETERS -# ============================================================================ -# Tune these to control data size, concurrency, and test duration. -# Uncomment the other benchmark section to switch between TPC-C and TPC-H. -hammerdb: - tprocc: - warehouses: 100 # data size: 100 = ~10GB, 1000 = ~100GB, 10000 = ~1TB per target - build_virtual_users: 4 # parallel threads for schema creation - load_virtual_users: 4 # concurrent users during benchmark (tune to match CPU cores) - driver: timed # "timed" = run for fixed duration, "test" = single iteration - rampup: 5 # warm-up period before measuring (minutes) - duration: 10 # measurement window (minutes), 5-60 typical - total_iterations: 10000000 # max iterations (effectively unlimited with timed driver) - all_warehouses: true # distribute load across all warehouses - checkpoint: true # issue checkpoint before benchmark - time_profile: false # detailed per-transaction timing (adds overhead) -# tproch: -# scale_factor: 1 # data size multiplier: 1 = ~1GB, 10 = ~10GB, 100 = ~100GB -# build_threads: 4 # parallel threads for data generation -# build_virtual_users: 1 # HammerDB orchestrator (keep at 1) -# load_virtual_users: 1 # query concurrency: 1 = Power run, >1 = Throughput run -# total_querysets: 1 # number of full 22-query runs - mssql: - connection: - tcp: true - authentication: sql - odbc_driver: "ODBC Driver 18 for SQL Server" - encrypt_connection: true # TLS encryption (recommended) - trust_server_cert: true # accept self-signed certs - -# ============================================================================ -# KUBERNETES RESOURCES -# ============================================================================ -# Resource requests/limits for each HammerDB Job pod. -# Each target gets one pod — scale these based on virtual user count. -resources: - requests: - memory: "4Gi" - cpu: "4" - limits: - memory: "8Gi" - cpu: "8" - -# ============================================================================ -# KUBERNETES SETTINGS -# ============================================================================ -kubernetes: - namespace: hammerdb # namespace for benchmark jobs (must exist) - job_ttl: 86400 # auto-cleanup completed jobs after N seconds (24h) - -# ============================================================================ -# PURE STORAGE METRICS (optional) -# Uncomment to collect IOPS, latency, and bandwidth from a FlashArray -# during benchmarks. Metrics appear in the HTML scorecard. -# ============================================================================ -storage_metrics: - enabled: false - # pure: - # host: "" # FlashArray management IP or hostname - # api_token: "" # REST API token (Settings > Users > API Tokens) - # volume: "" # leave empty for array-level metrics - # poll_interval: 5 # collection interval in seconds - # verify_ssl: false diff --git a/values.yaml b/values.yaml new file mode 120000 index 0000000..f02311d --- /dev/null +++ b/values.yaml @@ -0,0 +1 @@ +src/hammerdb_scale/chart/values.yaml \ No newline at end of file