From 723513239d53dc77f0e78ef28b6bce698f6c8990 Mon Sep 17 00:00:00 2001 From: Prateek Kumar Date: Tue, 15 Sep 2026 10:29:21 -0700 Subject: [PATCH 1/3] Add support for PHP resp-bench Signed-off-by: Kumar --- .github/workflows/benchmark.yml | 116 +++++- Makefile | 49 +++ README.md | 7 +- configs/drivers/default/valkey-glide-php.json | 7 + .../example-valkey-glide-php-standalone.json | 7 + .../high-throughput/valkey-glide-php.json | 7 + docs/BENCHMARKS_PHP.md | 69 ++++ php/.gitignore | 5 + php/README.md | 197 +++++++++++ php/bin/resp-bench | 23 ++ php/composer.json | 33 ++ php/phpunit.xml | 20 ++ php/src/Cli.php | 178 ++++++++++ php/src/Client/BenchmarkClient.php | 55 +++ php/src/Client/Factory.php | 50 +++ php/src/Client/Impl/RecordingClient.php | 94 +++++ php/src/Client/Impl/ValkeyGlidePhpClient.php | 175 +++++++++ php/src/Client/TimedResult.php | 30 ++ php/src/Command/Command.php | 31 ++ php/src/Command/CommandResult.php | 18 + php/src/Command/Factory.php | 54 +++ php/src/Command/Impl/GetCommand.php | 21 ++ php/src/Command/Impl/PingCommand.php | 20 ++ php/src/Command/Impl/SetCommand.php | 45 +++ php/src/Config/CommandConfig.php | 27 ++ php/src/Config/CompletionConfig.php | 38 ++ php/src/Config/DriverConfig.php | 50 +++ php/src/Config/KeyspaceConfig.php | 49 +++ php/src/Config/Loader.php | 171 +++++++++ php/src/Config/PhaseConfig.php | 46 +++ php/src/Config/WorkloadConfig.php | 37 ++ php/src/Engine/Benchmark.php | 331 ++++++++++++++++++ php/src/Engine/CommandSelector.php | 65 ++++ php/src/Engine/JavaRandom.php | 103 ++++++ php/src/Engine/KeyGenerator.php | 95 +++++ php/src/Engine/RateLimiter.php | 80 +++++ php/src/Metrics/Collector.php | 204 +++++++++++ php/src/Metrics/HdrEncoder.php | 155 ++++++++ php/src/Metrics/HdrHistogram.php | 255 ++++++++++++++ php/src/Metrics/NdjsonWriter.php | 156 +++++++++ php/src/Version.php | 10 + .../Integration/RecordingWorkloadTest.php | 128 +++++++ php/tests/Unit/CommandSelectorTest.php | 43 +++ php/tests/Unit/ConfigLoaderTest.php | 107 ++++++ php/tests/Unit/HdrEncoderTest.php | 84 +++++ php/tests/Unit/JavaRandomTest.php | 112 ++++++ php/tests/Unit/KeyGeneratorTest.php | 129 +++++++ php/tests/Unit/RateLimiterTest.php | 52 +++ php/tests/fixtures/recording-driver.json | 7 + php/tests/fixtures/smoke-workload.json | 45 +++ scripts/generate_graphs.py | 2 + scripts/run_benchmark_matrix.py | 2 + 52 files changed, 3892 insertions(+), 2 deletions(-) create mode 100644 configs/drivers/default/valkey-glide-php.json create mode 100644 configs/drivers/example-valkey-glide-php-standalone.json create mode 100644 configs/drivers/high-throughput/valkey-glide-php.json create mode 100644 docs/BENCHMARKS_PHP.md create mode 100644 php/.gitignore create mode 100644 php/README.md create mode 100755 php/bin/resp-bench create mode 100644 php/composer.json create mode 100644 php/phpunit.xml create mode 100644 php/src/Cli.php create mode 100644 php/src/Client/BenchmarkClient.php create mode 100644 php/src/Client/Factory.php create mode 100644 php/src/Client/Impl/RecordingClient.php create mode 100644 php/src/Client/Impl/ValkeyGlidePhpClient.php create mode 100644 php/src/Client/TimedResult.php create mode 100644 php/src/Command/Command.php create mode 100644 php/src/Command/CommandResult.php create mode 100644 php/src/Command/Factory.php create mode 100644 php/src/Command/Impl/GetCommand.php create mode 100644 php/src/Command/Impl/PingCommand.php create mode 100644 php/src/Command/Impl/SetCommand.php create mode 100644 php/src/Config/CommandConfig.php create mode 100644 php/src/Config/CompletionConfig.php create mode 100644 php/src/Config/DriverConfig.php create mode 100644 php/src/Config/KeyspaceConfig.php create mode 100644 php/src/Config/Loader.php create mode 100644 php/src/Config/PhaseConfig.php create mode 100644 php/src/Config/WorkloadConfig.php create mode 100644 php/src/Engine/Benchmark.php create mode 100644 php/src/Engine/CommandSelector.php create mode 100644 php/src/Engine/JavaRandom.php create mode 100644 php/src/Engine/KeyGenerator.php create mode 100644 php/src/Engine/RateLimiter.php create mode 100644 php/src/Metrics/Collector.php create mode 100644 php/src/Metrics/HdrEncoder.php create mode 100644 php/src/Metrics/HdrHistogram.php create mode 100644 php/src/Metrics/NdjsonWriter.php create mode 100644 php/src/Version.php create mode 100644 php/tests/Integration/RecordingWorkloadTest.php create mode 100644 php/tests/Unit/CommandSelectorTest.php create mode 100644 php/tests/Unit/ConfigLoaderTest.php create mode 100644 php/tests/Unit/HdrEncoderTest.php create mode 100644 php/tests/Unit/JavaRandomTest.php create mode 100644 php/tests/Unit/KeyGeneratorTest.php create mode 100644 php/tests/Unit/RateLimiterTest.php create mode 100644 php/tests/fixtures/recording-driver.json create mode 100644 php/tests/fixtures/smoke-workload.json diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 8aebd34..d42a584 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -152,8 +152,122 @@ jobs: path: ${{ steps.names.outputs.result_file }} retention-days: 30 + test-php: + # Fast, server-free PHP engine tests. Uses the recording driver, so it needs + # neither the valkey_glide extension nor a running server. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: json, pcntl + tools: composer + + - name: Install PHP dependencies + run: cd php && composer install --no-interaction --prefer-dist + + - name: Run PHP unit tests + run: cd php && vendor/bin/phpunit --testsuite unit + + - name: Run PHP integration tests (server-free) + run: cd php && vendor/bin/phpunit --testsuite integration + + benchmark-php: + runs-on: ubuntu-latest + + strategy: + fail-fast: false + matrix: + driver: + - configs/drivers/default/valkey-glide-php.json + workload: + - configs/workloads/reference/basic-standalone-single-client-1M-reqs.json + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: json, pcntl, bcmath + tools: composer + + - name: Install build dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential php-dev pkg-config \ + libssl-dev libffi-dev libprotobuf-c-dev protobuf-c-compiler unzip + + - name: Install Rust toolchain + uses: dtolnay/rust-toolchain@stable + + - name: Install cbindgen + run: cargo install cbindgen + + - name: Build & install valkey_glide extension + run: | + # Build the Valkey GLIDE PHP extension from the upstream repo. + git clone --recurse-submodules https://github.com/valkey-io/valkey-glide-php.git /tmp/vg-php + cd /tmp/vg-php + python3 utils/patch_proto_and_rust.py + (cd valkey-glide/ffi && cargo build --release) + phpize + ./configure --enable-valkey-glide + make build-modules-pre + make -j"$(nproc)" + sudo make install + echo "extension=valkey_glide" | sudo tee -a "$(php -i | grep '^Loaded Configuration File' | awk '{print $NF}')" + php -m | grep valkey_glide + + - name: Install PHP dependencies + run: cd php && composer install --no-interaction --prefer-dist --optimize-autoloader + + - name: Start Valkey server + run: | + make server-standalone-start + sleep 2 + work/valkey/bin/valkey-cli ping + work/valkey/bin/valkey-cli CONFIG GET save + + - name: Extract names for result file + id: names + run: | + DRIVER_NAME=$(basename ${{ matrix.driver }} .json) + WORKLOAD_NAME=$(basename ${{ matrix.workload }} .json) + echo "driver_name=$DRIVER_NAME" >> $GITHUB_OUTPUT + echo "workload_name=$WORKLOAD_NAME" >> $GITHUB_OUTPUT + echo "result_file=results/github-runner/reference/${DRIVER_NAME}-${WORKLOAD_NAME}.ndjson" >> $GITHUB_OUTPUT + + - name: Run benchmark + run: | + mkdir -p results/github-runner/reference + php php/bin/resp-bench \ + --server localhost:6379 \ + --driver ${{ matrix.driver }} \ + --workload ${{ matrix.workload }} \ + --metrics ${{ steps.names.outputs.result_file }} \ + --commit-id ${{ github.sha }} + + - name: Stop Valkey server + if: always() + run: | + make server-standalone-stop || true + + - name: Upload results + uses: actions/upload-artifact@v4 + with: + name: benchmark-php-${{ steps.names.outputs.driver_name }}-${{ steps.names.outputs.workload_name }} + path: ${{ steps.names.outputs.result_file }} + retention-days: 30 + generate-graphs: - needs: [benchmark-java, benchmark-ruby] + needs: [benchmark-java, benchmark-ruby, benchmark-php] runs-on: ubuntu-latest permissions: contents: write diff --git a/Makefile b/Makefile index db7d5d2..a6f7bba 100644 --- a/Makefile +++ b/Makefile @@ -34,6 +34,7 @@ WORK_DIR=$(shell pwd)/work python-build python-test python-run python-clean \ ruby-build ruby-test ruby-run ruby-clean ruby-info \ csharp-build csharp-test csharp-run csharp-clean csharp-info \ + php-build php-test php-integration-test php-run php-clean php-info \ config-editor-build config-editor-dev # ============================================================================ @@ -387,6 +388,54 @@ csharp-clean: csharp-info: csharp-build dotnet run --project $(CSHARP_PROJECT) -c Release -- --info +# ============================================================================ +# PHP Engine +# ============================================================================ + +# Prefer composer's autoloader; the CLI falls back to a minimal PSR-4 loader, +# so php-run/php-info work even before `composer install`. +PHP?=php + +php-build: + cd php && if command -v composer >/dev/null 2>&1; then \ + composer install --no-interaction --prefer-dist --optimize-autoloader; \ + else \ + echo "composer not found; using the bundled minimal autoloader (php/vendor/autoload.php)"; \ + fi + +php-test: + cd php && if [ -f vendor/bin/phpunit ]; then \ + vendor/bin/phpunit --testsuite unit; \ + elif [ -f /tmp/phpunit.phar ]; then \ + $(PHP) /tmp/phpunit.phar --testsuite unit; \ + else \ + echo "PHPUnit not installed. Run 'make php-build' (composer) or download phpunit.phar."; \ + exit 1; \ + fi + +php-integration-test: + cd php && if [ -f vendor/bin/phpunit ]; then \ + vendor/bin/phpunit --testsuite integration; \ + elif [ -f /tmp/phpunit.phar ]; then \ + $(PHP) /tmp/phpunit.phar --testsuite integration; \ + else \ + echo "PHPUnit not installed. Run 'make php-build' (composer) or download phpunit.phar."; \ + exit 1; \ + fi + +php-run: php-build + $(PHP) php/bin/resp-bench \ + --server $(SERVER) \ + --driver $(DRIVER) \ + --workload $(WORKLOAD) \ + --metrics $(METRICS_OUTPUT) + +php-clean: + cd php && rm -rf vendor composer.lock .phpunit.cache output + +php-info: + $(PHP) php/bin/resp-bench --info + # ============================================================================ # Config Editor # ============================================================================ diff --git a/README.md b/README.md index 5f55ed1..5022f41 100644 --- a/README.md +++ b/README.md @@ -103,6 +103,7 @@ Thread-based system metrics collector that runs alongside benchmarks, collecting | Java | ✅ Ready | Jedis, Lettuce, Valkey-Glide, Redisson, Spring Data Valkey/Redis | | Ruby | ✅ Ready | redis-rb, valkey-glide-ruby | | C# | ✅ Ready | valkey-glide-csharp, StackExchange.Redis | +| PHP | ✅ Ready | valkey-glide-php | | Python | 🚧 Planned | redis-py, aioredis, valkey-glide | ## Project Structure @@ -133,6 +134,7 @@ resp-bench/ ├── java/ # Java benchmark engine ├── ruby/ # Ruby benchmark engine ├── csharp/ # C# (.NET 10) benchmark engine +├── php/ # PHP benchmark engine ├── docs/ │ ├── ARCHITECTURE.md # System architecture │ ├── BENCHMARK_MATRIX.md # Matrix orchestrator docs @@ -140,7 +142,8 @@ resp-bench/ │ ├── CONFIG_SPECIFICATION.md # Configuration format spec │ ├── BENCHMARKS_JAVA.md # Java benchmark details │ ├── BENCHMARKS_CSHARP.md # C# benchmark details -│ └── BENCHMARKS_RUBY.md # Ruby benchmark details +│ ├── BENCHMARKS_RUBY.md # Ruby benchmark details +│ └── BENCHMARKS_PHP.md # PHP benchmark details └── graphs/interactive/ # Generated HTML graphs ``` @@ -197,6 +200,7 @@ See [docs/CONFIG_SPECIFICATION.md](docs/CONFIG_SPECIFICATION.md) for full detail | `make java-test` | Run Java unit tests | | `make ruby-test` | Run Ruby tests | | `make csharp-test` | Run C# tests | +| `make php-test` | Run PHP unit tests | ### Engines @@ -205,6 +209,7 @@ See [docs/CONFIG_SPECIFICATION.md](docs/CONFIG_SPECIFICATION.md) for full detail | `make java-run` | Run Java engine (DRIVER, WORKLOAD, SERVER) | | `make ruby-run` | Run Ruby engine (DRIVER, WORKLOAD, SERVER) | | `make csharp-run` | Run C# engine (DRIVER, WORKLOAD, SERVER) | +| `make php-run` | Run PHP engine (DRIVER, WORKLOAD, SERVER) | | `make java-build` | Build Java JAR | | `make csharp-build` | Build C# executable | diff --git a/configs/drivers/default/valkey-glide-php.json b/configs/drivers/default/valkey-glide-php.json new file mode 100644 index 0000000..5d79433 --- /dev/null +++ b/configs/drivers/default/valkey-glide-php.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "Valkey GLIDE PHP - default configuration", + "driver_id": "valkey-glide-php", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/configs/drivers/example-valkey-glide-php-standalone.json b/configs/drivers/example-valkey-glide-php-standalone.json new file mode 100644 index 0000000..1a1c121 --- /dev/null +++ b/configs/drivers/example-valkey-glide-php-standalone.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "Valkey GLIDE PHP client - standalone mode", + "driver_id": "valkey-glide-php", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/configs/drivers/high-throughput/valkey-glide-php.json b/configs/drivers/high-throughput/valkey-glide-php.json new file mode 100644 index 0000000..c800749 --- /dev/null +++ b/configs/drivers/high-throughput/valkey-glide-php.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "Valkey GLIDE PHP - high-throughput configuration", + "driver_id": "valkey-glide-php", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/docs/BENCHMARKS_PHP.md b/docs/BENCHMARKS_PHP.md new file mode 100644 index 0000000..40b6dba --- /dev/null +++ b/docs/BENCHMARKS_PHP.md @@ -0,0 +1,69 @@ +# PHP Client Benchmarks — Full Details + +Performance benchmarking of PHP client libraries against RESP-compatible servers, +using the resp-bench PHP engine. + +> **Note**: Graphs are generated by CI once benchmark runs are published. Until +> then, this document describes the engine, drivers, and workload. See +> [../php/README.md](../php/README.md) for engine internals. + +## Drivers Tested + +| Driver | Package | Description | +|--------|---------|-------------| +| `valkey-glide-php` | [ext-valkey_glide](https://github.com/valkey-io/valkey-glide-php) | Valkey GLIDE PHP client — a native PHP extension backed by a Rust core, exposing a PHPRedis-compatible API | + +The `recording` driver is used for server-free CI tests and is not part of the +performance comparison. + +## Engine Notes + +- **Language/runtime**: PHP 8.2+ (GLIDE extension tested on 8.2/8.3). +- **Concurrency**: process-per-connection via `pcntl` (capped at 256 workers). + Each worker owns one client connection, opened *after* forking, and keeps a + single request in flight — matching the "one in-flight request per connection" + model used by the Java, Ruby, C#, and Node engines so results are comparable. +- **Metrics**: HdrHistogram range `(1, 600_000_000, 3)`; per-worker histograms + are merged losslessly in the parent process and emitted as Java-compatible V2 + compressed base64 payloads. +- **Rate limiting**: leaky-bucket, with phase-level `rps_limit` divided across + workers. + +## Prerequisites for live runs + +- The `valkey_glide` PHP extension installed and enabled (`extension=valkey_glide`). +- `ext-pcntl` (standard on Linux/macOS CLI PHP). + +See [../php/README.md](../php/README.md#installing-the-valkey-glide-php-extension) +for installation. + +## Running + +```bash +make server-standalone-start +make php-run \ + DRIVER=configs/drivers/example-valkey-glide-php-standalone.json \ + WORKLOAD=configs/workloads/reference/basic-standalone-single-client-1M-reqs.json \ + SERVER=localhost:6379 \ + METRICS_OUTPUT=output/php.ndjson +``` + +Or through the matrix orchestrator (the `valkey-glide-php` driver id maps to the +`php` engine): + +```bash +python scripts/run_benchmark_matrix.py \ + --matrix configs/matrices/.json \ + --output-dir results/php-run \ + --server-host localhost +``` + +## Workload Configuration + +PHP runs use the same reference workloads as the other engines (see +`configs/workloads/reference/`), so cross-language comparisons are apples-to-apples: +- 1M requests per steady phase +- 1M keys, 16-byte keys, `bench:` prefix +- 512-byte SET values +- GET/SET mix via uniform-random key selection +- No rate limiting for max-throughput runs diff --git a/php/.gitignore b/php/.gitignore new file mode 100644 index 0000000..13a49cc --- /dev/null +++ b/php/.gitignore @@ -0,0 +1,5 @@ +/vendor/ +/composer.lock +/.phpunit.cache/ +/output/ +*.ndjson diff --git a/php/README.md b/php/README.md new file mode 100644 index 0000000..d93c08d --- /dev/null +++ b/php/README.md @@ -0,0 +1,197 @@ +# resp-bench PHP Engine + +PHP implementation of the resp-bench benchmark suite for Redis/Valkey compatible databases. + +## Supported Drivers + +| Driver ID | Package | Description | +|-----------|---------|-------------| +| `valkey-glide-php` | [ext-valkey_glide](https://github.com/valkey-io/valkey-glide-php) | Valkey GLIDE PHP client — Rust-core client exposed as a native PHP extension, with a PHPRedis-compatible API | +| `recording` | (built-in) | In-memory driver for server-free tests and pipeline validation | + +## Prerequisites + +- PHP 8.2 or 8.3 (the GLIDE PHP extension's tested versions; the engine itself runs on 8.2+) +- `ext-pcntl` (for the multi-process concurrency model; standard on Linux/macOS CLI builds) +- `ext-json` (bundled with PHP) +- Composer (recommended) — or use the bundled minimal autoloader +- For live-server runs: the `valkey_glide` extension installed and enabled + +### Installing the Valkey GLIDE PHP extension + +The `valkey-glide-php` driver requires the `valkey_glide` PHP extension. Install it +via `pie`, PECL, or from source — see the +[upstream instructions](https://github.com/valkey-io/valkey-glide-php#installation-and-setup). +After installing, enable it in `php.ini`: + +```ini +extension=valkey_glide +``` + +Verify: + +```bash +php -m | grep valkey_glide +``` + +The `recording` driver needs neither the extension nor a server, so unit and +integration tests run without either. + +## Installation + +```bash +cd php +composer install +``` + +If Composer is unavailable, the CLI falls back to a bundled minimal PSR-4 +autoloader (`php/vendor/autoload.php`), so `make php-run` / `make php-info` still work. + +## Usage + +### Command line + +```bash +# Run a benchmark +php php/bin/resp-bench \ + --server localhost:6379 \ + --driver configs/drivers/example-valkey-glide-php-standalone.json \ + --workload configs/workloads/example-workload.json \ + --metrics output/php.ndjson + +# Show supported drivers and commands (also reports extension/pcntl availability) +php php/bin/resp-bench --info + +# Help +php php/bin/resp-bench --help +``` + +### Using Make (from project root) + +```bash +make php-build # composer install (or bundled autoloader) +make php-test # unit tests +make php-integration-test # integration tests (server-free, recording driver) +make php-run \ + DRIVER=configs/drivers/example-valkey-glide-php-standalone.json \ + WORKLOAD=configs/workloads/example-workload.json \ + SERVER=localhost:6379 +``` + +## Architecture + +The PHP engine follows the same architecture as the Java reference implementation: + +``` +src/ +├── Client/ # Client interface + implementations +│ ├── BenchmarkClient.php # Abstract base +│ ├── TimedResult.php +│ ├── Factory.php +│ └── Impl/ +│ ├── ValkeyGlidePhpClient.php # ext-valkey_glide +│ └── RecordingClient.php # in-memory, server-free +├── Command/ # GET / SET / PING commands + Factory +├── Config/ # JSON config parsing (Driver/Workload/Phase/…) +├── Engine/ +│ ├── Benchmark.php # Multi-process orchestrator +│ ├── CommandSelector.php # Weighted selection +│ ├── JavaRandom.php # Java-compatible LCG +│ ├── KeyGenerator.php +│ └── RateLimiter.php # Leaky bucket +└── Metrics/ + ├── Collector.php # Per-command metrics + merge + ├── HdrHistogram.php # Pure-PHP HdrHistogram + ├── HdrEncoder.php # V2 compressed (Java-compatible) encoding + └── NdjsonWriter.php # NDJSON output +``` + +## Concurrency model + +The engine uses a **process-per-connection** model (the PHP analogue of Java's +virtual-thread-per-client and Ruby's thread-per-client): + +- `connections = N` → fork **N worker processes** (capped at 256). +- Each worker connects to the server **after** forking — a connection is never + inherited across a fork, which is required for correctness with the native + extension. +- Each worker runs one in-flight request at a time against its own client + (the `client == connection` invariant), matching the other engines so results + are comparable. +- Workers stream partial metrics back to the parent over a `stream_socket_pair`; + the parent reconstructs and **merges the HdrHistograms losslessly** (sparse + bucket counts) before writing NDJSON. +- Phase-level `rps_limit` is divided across workers so the aggregate matches the + target rate. + +The chosen model is driven by PHP's runtime: the GLIDE extension is synchronous, +and the standard PHP build is non-thread-safe (NTS), which rules out +`ext-parallel`. `pcntl` multi-processing is the faithful, dependency-free option. + +An **`inline`** mode (`--concurrency inline`) runs all connections sequentially in +a single process. It can't produce true concurrency with a blocking client, but +it exercises the full pipeline and is used for server-free tests. It is selected +automatically for the `recording` driver and when `pcntl` is unavailable. + +## Cross-language parity + +- **JavaRandom**: a port of `java.util.Random`'s 48-bit LCG, verified + byte-identical to Java's canonical `new Random(0).nextInt()` sequence. PHP's + lack of 64-bit integer overflow wraparound is handled with a 24-bit split + multiply. +- **Key generation**: `sequential_int` walks the keyspace; `uniform_rand` seeds + per worker as `seed + workerIndex` for reproducible-yet-distinct sequences. + Key formatting uses `%0Nd` honoring `key_size_bytes`. +- **HdrHistogram**: range `(1, 600_000_000, 3)`; the V2 compressed base64 payload + is byte-compatible with Java's `encodeIntoCompressedByteBuffer()`. +- **Rate limiter**: leaky bucket, constant spacing, no burst. + +## Metrics output + +NDJSON compatible with all other language engines: + +```json +{ + "metadata": {"commit_id": "abc123", "timestamp": "2026-09-14T16:27:11Z", "driver_id": "valkey-glide-php", "primary_driver_version": "1.0.0"}, + "phase": {"id": "STEADY", "status": "COMPLETED", "duration_ms": 60000, "connections": 16}, + "totals": {"requests": 1000000, "errors": 0}, + "metrics": { + "GET": { + "requests": 800000, + "errors": 0, + "latency": {"unit": "us", "count": 800000, "summary": {"p50": 150, "p99": 450}, "hdr": {"format": "hdr", "sigfig": 3, "payload_b64": "..."}} + } + } +} +``` + +## Testing + +```bash +# From php/ with composer-installed PHPUnit: +vendor/bin/phpunit # all tests +vendor/bin/phpunit --testsuite unit # unit only +vendor/bin/phpunit --testsuite integration +``` + +### Test coverage (Java parity) + +| Java Test | PHP Test | Coverage | +|-----------|----------|----------| +| `JavaRandomTest` | `tests/Unit/JavaRandomTest.php` | LCG determinism, Java seed-0 anchor | +| `KeyGeneratorTest` | `tests/Unit/KeyGeneratorTest.php` | Sequential/wrap, uniform-rand, formatting, JavaRandom parity | +| `ConfigLoaderTest` | `tests/Unit/ConfigLoaderTest.php` | Driver/workload parsing, defaults, cluster mode | +| `RateLimiterTest` | `tests/Unit/RateLimiterTest.php` | Leaky-bucket rate enforcement | +| — | `tests/Unit/CommandSelectorTest.php` | Weighted command distribution | +| `MetricsOutputTest` | `tests/Unit/HdrEncoderTest.php` | HDR percentiles + V2 compressed encoding structure | +| `RecordingBenchmarkClientTest` | `tests/Integration/RecordingWorkloadTest.php` | Full pipeline → NDJSON schema, inline + process modes | + +## Adding a new driver + +1. Create a client in `src/Client/Impl/` extending `BenchmarkClient`. +2. Register it in `Client\Factory::DRIVERS`. +3. Add driver configs under `configs/drivers/`. + +## License + +Apache License 2.0 — see [LICENSE](../LICENSE) diff --git a/php/bin/resp-bench b/php/bin/resp-bench new file mode 100755 index 0000000..679b160 --- /dev/null +++ b/php/bin/resp-bench @@ -0,0 +1,23 @@ +#!/usr/bin/env php +run($argv)); diff --git a/php/composer.json b/php/composer.json new file mode 100644 index 0000000..eb0a3a5 --- /dev/null +++ b/php/composer.json @@ -0,0 +1,33 @@ +{ + "name": "valkey-io/resp-bench-php", + "description": "PHP benchmark engine for resp-bench (RESP protocol / Valkey / Redis)", + "type": "project", + "license": "Apache-2.0", + "require": { + "php": ">=8.2", + "ext-json": "*" + }, + "require-dev": { + "phpunit/phpunit": "^11.0" + }, + "suggest": { + "ext-valkey_glide": "Valkey GLIDE PHP client extension (required for the valkey-glide-php driver)", + "ext-pcntl": "Process control (required for the multi-process concurrency model on live-server runs)" + }, + "autoload": { + "psr-4": { + "RespBench\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "RespBench\\Tests\\": "tests/" + } + }, + "bin": [ + "bin/resp-bench" + ], + "config": { + "optimize-autoloader": true + } +} diff --git a/php/phpunit.xml b/php/phpunit.xml new file mode 100644 index 0000000..7ec4625 --- /dev/null +++ b/php/phpunit.xml @@ -0,0 +1,20 @@ + + + + + tests/Unit + + + tests/Integration + + + + + src + + + diff --git a/php/src/Cli.php b/php/src/Cli.php new file mode 100644 index 0000000..d392aa3 --- /dev/null +++ b/php/src/Cli.php @@ -0,0 +1,178 @@ + */ + private array $options = [ + 'host' => 'localhost', + 'port' => 6379, + ]; + + /** + * @param list $argv + */ + public function run(array $argv): int + { + try { + $this->parseOptions($argv); + + if (!empty($this->options['info'])) { + $this->printInfo(); + + return 0; + } + + $this->validateOptions(); + $this->executeBenchmark(); + + return 0; + } catch (\Throwable $e) { + fwrite(STDERR, 'Error: ' . $e->getMessage() . "\n"); + if (getenv('DEBUG')) { + fwrite(STDERR, $e->getTraceAsString() . "\n"); + } + + return 1; + } + } + + /** + * @param list $argv + */ + private function parseOptions(array $argv): void + { + $args = array_slice($argv, 1); + $count = count($args); + + for ($i = 0; $i < $count; $i++) { + $arg = $args[$i]; + $next = static fn (): string => $args[++$i] ?? ''; + + switch ($arg) { + case '--server': + $server = $next(); + $parts = explode(':', $server); + $this->options['host'] = $parts[0]; + if (isset($parts[1])) { + $this->options['port'] = (int) $parts[1]; + } + break; + case '--driver': + $this->options['driver'] = $next(); + break; + case '--workload': + $this->options['workload'] = $next(); + break; + case '--metrics': + $this->options['metrics'] = $next(); + break; + case '--commit-id': + $this->options['commit_id'] = $next(); + break; + case '--concurrency': + $this->options['concurrency_mode'] = $next(); + break; + case '--info': + $this->options['info'] = true; + break; + case '-h': + case '--help': + $this->printHelp(); + exit(0); + case '-v': + case '--version': + echo 'resp-bench PHP Engine v' . Version::VERSION . "\n"; + exit(0); + default: + // ignore unknown args for forward compatibility + break; + } + } + } + + private function validateOptions(): void + { + $missing = []; + foreach (['driver', 'workload', 'metrics'] as $required) { + if (empty($this->options[$required])) { + $missing[] = "--{$required}"; + } + } + if ($missing !== []) { + throw new \InvalidArgumentException('Missing required options: ' . implode(', ', $missing)); + } + + if (!is_file((string) $this->options['driver'])) { + throw new \InvalidArgumentException('Driver config not found: ' . $this->options['driver']); + } + if (!is_file((string) $this->options['workload'])) { + throw new \InvalidArgumentException('Workload config not found: ' . $this->options['workload']); + } + } + + private function executeBenchmark(): void + { + $driverConfig = Loader::loadDriverConfig((string) $this->options['driver']); + $workloadConfig = Loader::loadWorkloadConfig((string) $this->options['workload']); + + $engine = new Benchmark( + host: (string) $this->options['host'], + port: (int) $this->options['port'], + driverConfig: $driverConfig, + workloadConfig: $workloadConfig, + metricsPath: (string) $this->options['metrics'], + commitId: isset($this->options['commit_id']) ? (string) $this->options['commit_id'] : null, + concurrencyMode: isset($this->options['concurrency_mode']) + ? (string) $this->options['concurrency_mode'] + : null, + ); + + $engine->run(); + } + + private function printInfo(): void + { + echo 'resp-bench PHP Engine v' . Version::VERSION . "\n\n"; + echo "Supported Drivers:\n"; + foreach (ClientFactory::supportedDrivers() as $driver) { + echo " - {$driver}\n"; + } + echo "\nSupported Commands:\n"; + foreach (CommandFactory::supportedCommands() as $cmd) { + echo " - {$cmd}\n"; + } + echo "\nConcurrency: process-per-connection (max 256), inline fallback\n"; + echo 'valkey_glide extension: ' . (extension_loaded('valkey_glide') ? 'loaded' : 'NOT loaded') . "\n"; + echo 'pcntl extension: ' . (function_exists('pcntl_fork') ? 'available' : 'NOT available') . "\n"; + } + + private function printHelp(): void + { + echo <<> */ + private const DRIVERS = [ + 'valkey-glide-php' => ValkeyGlidePhpClient::class, + 'recording' => RecordingClient::class, + ]; + + public static function create(string $driverId): BenchmarkClient + { + $class = self::DRIVERS[$driverId] ?? null; + if ($class === null) { + throw new InvalidArgumentException( + "Unknown driver: {$driverId}. Supported: " . implode(', ', array_keys(self::DRIVERS)) + ); + } + + return new $class(); + } + + public static function createAndConnect(string $host, int $port, DriverConfig $config): BenchmarkClient + { + $client = self::create((string) $config->driverId); + $client->connect($host, $port, $config); + + return $client; + } + + /** + * @return list + */ + public static function supportedDrivers(): array + { + return array_keys(self::DRIVERS); + } +} diff --git a/php/src/Client/Impl/RecordingClient.php b/php/src/Client/Impl/RecordingClient.php new file mode 100644 index 0000000..6459111 --- /dev/null +++ b/php/src/Client/Impl/RecordingClient.php @@ -0,0 +1,94 @@ + */ + private array $store = []; + + /** @var list */ + private array $recorded = []; + + private bool $connected = false; + + public function connect(string $host, int $port, DriverConfig $config): void + { + $this->connected = true; + } + + public function isConnected(): bool + { + return $this->connected; + } + + public function ping(): TimedResult + { + return $this->measure(function (): string { + $this->recorded[] = ['op' => 'PING', 'key' => '', 'size' => 0]; + + return 'PONG'; + }); + } + + public function get(string $key): TimedResult + { + return $this->measure(function () use ($key): ?string { + $this->recorded[] = ['op' => 'GET', 'key' => $key, 'size' => 0]; + + return $this->store[$key] ?? null; + }); + } + + public function set(string $key, string $value): TimedResult + { + return $this->measure(function () use ($key, $value): string { + $this->store[$key] = $value; + $this->recorded[] = ['op' => 'SET', 'key' => $key, 'size' => strlen($value)]; + + return 'OK'; + }); + } + + public function del(string $key): TimedResult + { + return $this->measure(function () use ($key): int { + $existed = isset($this->store[$key]); + unset($this->store[$key]); + $this->recorded[] = ['op' => 'DEL', 'key' => $key, 'size' => 0]; + + return $existed ? 1 : 0; + }); + } + + public function close(): void + { + $this->connected = false; + } + + public function driverVersion(): string + { + return 'recording-1.0'; + } + + /** + * @return list + */ + public function recordedOperations(): array + { + return $this->recorded; + } +} diff --git a/php/src/Client/Impl/ValkeyGlidePhpClient.php b/php/src/Client/Impl/ValkeyGlidePhpClient.php new file mode 100644 index 0000000..cb2a007 --- /dev/null +++ b/php/src/Client/Impl/ValkeyGlidePhpClient.php @@ -0,0 +1,175 @@ +connect(addresses: [['host' => 'localhost', 'port' => 6379]]); + * $client->set('foo', 'bar'); $client->get('foo'); $client->ping(); + * $client->close(); + * + * IMPORTANT: In the multi-process engine, each worker constructs and connects its + * own client AFTER forking — a connection is never inherited across a fork. + */ +final class ValkeyGlidePhpClient extends BenchmarkClient +{ + private ?object $client = null; + + public function connect(string $host, int $port, DriverConfig $config): void + { + if (!extension_loaded('valkey_glide')) { + throw new RuntimeException( + 'The valkey_glide PHP extension is not loaded. ' + . 'Install it (see php/README.md) and add extension=valkey_glide to php.ini.' + ); + } + + $addresses = [['host' => $host, 'port' => $port]]; + $useTls = false; + $advancedConfig = null; + $credentials = null; + + if ($config->tls !== null) { + $useTls = true; + $tlsConfig = []; + if (isset($config->tls['ca_cert_path'])) { + $tlsConfig['root_certs'] = (string) file_get_contents((string) $config->tls['ca_cert_path']); + } + if (isset($config->tls['cert_path'])) { + $tlsConfig['client_cert'] = (string) file_get_contents((string) $config->tls['cert_path']); + } + if (isset($config->tls['key_path'])) { + $tlsConfig['client_key'] = (string) file_get_contents((string) $config->tls['key_path']); + } + if ($tlsConfig !== []) { + $advancedConfig = ['tls_config' => $tlsConfig]; + } + } + + if ($config->auth !== null) { + $credentials = []; + if (isset($config->auth['username'])) { + $credentials['username'] = (string) $config->auth['username']; + } + if (isset($config->auth['password'])) { + $credentials['password'] = (string) $config->auth['password']; + } + } + + $this->client = $config->isCluster() + ? $this->makeClient('ValkeyGlideCluster', $addresses, $useTls, $advancedConfig, $credentials) + : $this->makeClient('ValkeyGlide', $addresses, $useTls, $advancedConfig, $credentials); + } + + /** + * @param list $addresses + * @param array|null $advancedConfig + * @param array|null $credentials + */ + private function makeClient( + string $class, + array $addresses, + bool $useTls, + ?array $advancedConfig, + ?array $credentials, + ): object { + if (!class_exists($class)) { + throw new RuntimeException("{$class} class not available from the valkey_glide extension."); + } + + // ValkeyGlideCluster connects via constructor; ValkeyGlide via connect(). + if ($class === 'ValkeyGlideCluster') { + /** @psalm-suppress MixedMethodCall */ + return new $class( + addresses: $addresses, + use_tls: $useTls, + credentials: $credentials, + advanced_config: $advancedConfig, + ); + } + + /** @psalm-suppress MixedMethodCall */ + $client = new $class(); + $client->connect( + addresses: $addresses, + use_tls: $useTls, + credentials: $credentials, + advanced_config: $advancedConfig, + ); + + return $client; + } + + public function isConnected(): bool + { + if ($this->client === null) { + return false; + } + + try { + return $this->client->ping() !== false; + } catch (\Throwable) { + return false; + } + } + + public function ping(): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->ping()); + } + + public function get(string $key): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->get($key)); + } + + public function set(string $key, string $value): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->set($key, $value)); + } + + public function del(string $key): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->del($key)); + } + + public function close(): void + { + if ($this->client !== null) { + try { + $this->client->close(); + } catch (\Throwable) { + // ignore close errors + } + $this->client = null; + } + } + + public function driverVersion(): string + { + $version = phpversion('valkey_glide'); + + return $version !== false ? $version : 'unknown'; + } + + private function requireClient(): object + { + if ($this->client === null) { + throw new RuntimeException('ValkeyGlide client is not connected.'); + } + + return $this->client; + } +} diff --git a/php/src/Client/TimedResult.php b/php/src/Client/TimedResult.php new file mode 100644 index 0000000..7306e31 --- /dev/null +++ b/php/src/Client/TimedResult.php @@ -0,0 +1,30 @@ +error === null; + } + + public function isError(): bool + { + return $this->error !== null; + } +} diff --git a/php/src/Command/Command.php b/php/src/Command/Command.php new file mode 100644 index 0000000..46fa0d8 --- /dev/null +++ b/php/src/Command/Command.php @@ -0,0 +1,31 @@ +weight = $config->weight; + $this->name = strtoupper($config->command); + $this->dataSizeBytes = $config->dataSizeBytes; + } + + /** + * Execute the command and return a result for metrics. + */ + abstract public function execute(BenchmarkClient $client, KeyGenerator $keyGenerator): CommandResult; +} diff --git a/php/src/Command/CommandResult.php b/php/src/Command/CommandResult.php new file mode 100644 index 0000000..dda55c0 --- /dev/null +++ b/php/src/Command/CommandResult.php @@ -0,0 +1,18 @@ +> */ + private const COMMAND_CLASSES = [ + 'ping' => PingCommand::class, + 'get' => GetCommand::class, + 'set' => SetCommand::class, + ]; + + public static function create(CommandConfig $config): Command + { + $class = self::COMMAND_CLASSES[$config->command] ?? null; + if ($class === null) { + throw new InvalidArgumentException( + "Unknown command: {$config->command}. Supported: " + . implode(', ', array_keys(self::COMMAND_CLASSES)) + ); + } + + return new $class($config); + } + + /** + * @param list $configs + * @return list + */ + public static function createAll(array $configs): array + { + return array_map(static fn (CommandConfig $c): Command => self::create($c), $configs); + } + + /** + * @return list + */ + public static function supportedCommands(): array + { + return array_keys(self::COMMAND_CLASSES); + } +} diff --git a/php/src/Command/Impl/GetCommand.php b/php/src/Command/Impl/GetCommand.php new file mode 100644 index 0000000..ac404ec --- /dev/null +++ b/php/src/Command/Impl/GetCommand.php @@ -0,0 +1,21 @@ +nextKey(); + $result = $client->get($key); + + return new CommandResult($this->name, $result->latencyMicros, $result->isSuccess()); + } +} diff --git a/php/src/Command/Impl/PingCommand.php b/php/src/Command/Impl/PingCommand.php new file mode 100644 index 0000000..bbb4d10 --- /dev/null +++ b/php/src/Command/Impl/PingCommand.php @@ -0,0 +1,20 @@ +ping(); + + return new CommandResult($this->name, $result->latencyMicros, $result->isSuccess()); + } +} diff --git a/php/src/Command/Impl/SetCommand.php b/php/src/Command/Impl/SetCommand.php new file mode 100644 index 0000000..3f4cb17 --- /dev/null +++ b/php/src/Command/Impl/SetCommand.php @@ -0,0 +1,45 @@ +value = self::generateValue($this->dataSizeBytes); + } + + public function execute(BenchmarkClient $client, KeyGenerator $keyGenerator): CommandResult + { + $key = $keyGenerator->nextKey(); + $result = $client->set($key, $this->value); + + return new CommandResult($this->name, $result->latencyMicros, $result->isSuccess()); + } + + /** + * Deterministic value of the requested size (matches the Ruby engine pattern). + */ + private static function generateValue(int $size): string + { + if ($size <= 0) { + return ''; + } + + $pattern = '0123456789ABCDEF'; + $repeat = intdiv($size, strlen($pattern)) + 1; + + return substr(str_repeat($pattern, $repeat), 0, $size); + } +} diff --git a/php/src/Config/CommandConfig.php b/php/src/Config/CommandConfig.php new file mode 100644 index 0000000..46ddcc9 --- /dev/null +++ b/php/src/Config/CommandConfig.php @@ -0,0 +1,27 @@ +command = strtolower($command); + $this->weight = $weight; + $this->dataSizeBytes = $dataSizeBytes ?? self::DEFAULT_DATA_SIZE_BYTES; + } +} diff --git a/php/src/Config/CompletionConfig.php b/php/src/Config/CompletionConfig.php new file mode 100644 index 0000000..45ef270 --- /dev/null +++ b/php/src/Config/CompletionConfig.php @@ -0,0 +1,38 @@ +type === 'duration'; + } + + public function isRequestBased(): bool + { + return $this->type === 'requests'; + } + + public function durationSeconds(): int + { + return $this->seconds ?? 0; + } + + public function totalRequests(): int + { + return $this->requests ?? 0; + } +} diff --git a/php/src/Config/DriverConfig.php b/php/src/Config/DriverConfig.php new file mode 100644 index 0000000..951eefa --- /dev/null +++ b/php/src/Config/DriverConfig.php @@ -0,0 +1,50 @@ + $specificDriverConfig + * @param array|null $tls + * @param array|null $auth + */ + public function __construct( + public readonly string $schemaVersion = '1.0', + public readonly ?string $description = null, + public readonly ?string $driverId = null, + public readonly string $mode = 'standalone', + public readonly ?array $tls = null, + public readonly ?array $auth = null, + public readonly array $specificDriverConfig = [], + ) { + } + + public function secondaryDriverId(): ?string + { + $value = $this->specificDriverConfig['secondary_driver_id'] ?? null; + + return is_string($value) ? $value : null; + } + + public function isStandalone(): bool + { + return $this->mode === 'standalone'; + } + + public function isCluster(): bool + { + return $this->mode === 'cluster'; + } + + public function isSentinel(): bool + { + return $this->mode === 'sentinel'; + } +} diff --git a/php/src/Config/KeyspaceConfig.php b/php/src/Config/KeyspaceConfig.php new file mode 100644 index 0000000..283fcfa --- /dev/null +++ b/php/src/Config/KeyspaceConfig.php @@ -0,0 +1,49 @@ +generationAlg === 'sequential_int'; + } + + public function isUniformRand(): bool + { + return $this->generationAlg === 'uniform_rand'; + } + + public function effectiveKeyPrefix(): string + { + return $this->keyPrefix !== '' ? $this->keyPrefix : self::DEFAULT_KEY_PREFIX; + } + + /** + * Returns the seed value (defaults to 0 if not set), matching Ruby's seed_value. + */ + public function seedValue(): int + { + return $this->seed ?? 0; + } +} diff --git a/php/src/Config/Loader.php b/php/src/Config/Loader.php new file mode 100644 index 0000000..32ae2a2 --- /dev/null +++ b/php/src/Config/Loader.php @@ -0,0 +1,171 @@ + $json + */ + public static function parseDriverConfig(array $json): DriverConfig + { + return new DriverConfig( + schemaVersion: (string) ($json['schema_version'] ?? '1.0'), + description: isset($json['description']) ? (string) $json['description'] : null, + driverId: isset($json['driver_id']) ? (string) $json['driver_id'] : null, + mode: (string) ($json['mode'] ?? 'standalone'), + tls: isset($json['tls']) && is_array($json['tls']) ? $json['tls'] : null, + auth: isset($json['auth']) && is_array($json['auth']) ? $json['auth'] : null, + specificDriverConfig: isset($json['specific_driver_config']) && is_array($json['specific_driver_config']) + ? $json['specific_driver_config'] + : [], + ); + } + + /** + * @param array $json + */ + public static function parseWorkloadConfig(array $json): WorkloadConfig + { + $phases = []; + foreach (($json['phases'] ?? []) as $phase) { + $phases[] = self::parsePhaseConfig($phase); + } + + return new WorkloadConfig( + schemaVersion: (string) ($json['schema_version'] ?? '1.0'), + benchmarkProfile: isset($json['benchmark_profile']) && is_array($json['benchmark_profile']) + ? $json['benchmark_profile'] + : [], + phases: $phases, + ); + } + + /** + * @param array $json + */ + private static function parsePhaseConfig(array $json): PhaseConfig + { + $commands = []; + foreach (($json['commands'] ?? []) as $command) { + $commands[] = self::parseCommandConfig($command); + } + + return new PhaseConfig( + id: (string) $json['id'], + connections: (int) $json['connections'], + completion: self::parseCompletionConfig($json['completion'] ?? []), + keyspace: self::parseKeyspaceConfig($json['keyspace'] ?? []), + commands: $commands, + description: isset($json['description']) ? (string) $json['description'] : null, + cpsLimit: isset($json['cps_limit']) ? (int) $json['cps_limit'] : -1, + rpsLimit: isset($json['rps_limit']) ? (int) $json['rps_limit'] : -1, + pipelineDepth: isset($json['pipeline_depth']) + ? (int) $json['pipeline_depth'] + : PhaseConfig::DEFAULT_PIPELINE_DEPTH, + warmupRequests: isset($json['warmup_requests']) + ? (int) $json['warmup_requests'] + : PhaseConfig::DEFAULT_WARMUP_REQUESTS, + ); + } + + /** + * @param array $json + */ + private static function parseCompletionConfig(array $json): CompletionConfig + { + return new CompletionConfig( + type: (string) ($json['type'] ?? 'requests'), + seconds: isset($json['seconds']) ? (int) $json['seconds'] : null, + requests: isset($json['requests']) ? (int) $json['requests'] : null, + ); + } + + /** + * @param array $json + */ + private static function parseKeyspaceConfig(array $json): KeyspaceConfig + { + return new KeyspaceConfig( + keysCount: (int) ($json['keys_count'] ?? 1), + keySizeBytes: isset($json['key_size_bytes']) + ? (int) $json['key_size_bytes'] + : KeyspaceConfig::DEFAULT_KEY_SIZE_BYTES, + keyPrefix: isset($json['key_prefix']) + ? (string) $json['key_prefix'] + : KeyspaceConfig::DEFAULT_KEY_PREFIX, + generationAlg: (string) ($json['generation_alg'] ?? 'sequential_int'), + seed: isset($json['seed']) ? (int) $json['seed'] : null, + ); + } + + /** + * @param array $json + */ + private static function parseCommandConfig(array $json): CommandConfig + { + return new CommandConfig( + command: (string) $json['command'], + weight: (float) $json['weight'], + dataSizeBytes: isset($json['data_size_bytes']) + ? (int) $json['data_size_bytes'] + : CommandConfig::DEFAULT_DATA_SIZE_BYTES, + ); + } + + /** + * @return array + */ + private static function parseJsonFile(string $path): array + { + $content = @file_get_contents($path); + if ($content === false) { + throw new RuntimeException("Cannot read config file: {$path}"); + } + + return self::decode($content); + } + + /** + * @return array + */ + private static function decode(string $json): array + { + try { + /** @var array $decoded */ + $decoded = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + } catch (JsonException $e) { + throw new RuntimeException('Invalid JSON config: ' . $e->getMessage(), 0, $e); + } + + return $decoded; + } +} diff --git a/php/src/Config/PhaseConfig.php b/php/src/Config/PhaseConfig.php new file mode 100644 index 0000000..7d3409a --- /dev/null +++ b/php/src/Config/PhaseConfig.php @@ -0,0 +1,46 @@ + $commands + */ + public function __construct( + public readonly string $id, + public readonly int $connections, + public readonly CompletionConfig $completion, + public readonly KeyspaceConfig $keyspace, + public readonly array $commands, + public readonly ?string $description = null, + public readonly int $cpsLimit = -1, + public readonly int $rpsLimit = -1, + public readonly int $pipelineDepth = self::DEFAULT_PIPELINE_DEPTH, + public readonly int $warmupRequests = self::DEFAULT_WARMUP_REQUESTS, + ) { + } + + public function hasCpsLimit(): bool + { + return $this->cpsLimit > 0; + } + + public function hasRpsLimit(): bool + { + return $this->rpsLimit > 0; + } + + public function effectivePipelineDepth(): int + { + return $this->pipelineDepth > 0 ? $this->pipelineDepth : self::DEFAULT_PIPELINE_DEPTH; + } +} diff --git a/php/src/Config/WorkloadConfig.php b/php/src/Config/WorkloadConfig.php new file mode 100644 index 0000000..a5f3d6c --- /dev/null +++ b/php/src/Config/WorkloadConfig.php @@ -0,0 +1,37 @@ + $benchmarkProfile + * @param list $phases + */ + public function __construct( + public readonly string $schemaVersion, + public readonly array $benchmarkProfile, + public readonly array $phases, + ) { + } + + public function name(): ?string + { + $value = $this->benchmarkProfile['name'] ?? null; + + return is_string($value) ? $value : null; + } + + public function description(): ?string + { + $value = $this->benchmarkProfile['description'] ?? null; + + return is_string($value) ? $value : null; + } +} diff --git a/php/src/Engine/Benchmark.php b/php/src/Engine/Benchmark.php new file mode 100644 index 0000000..817540d --- /dev/null +++ b/php/src/Engine/Benchmark.php @@ -0,0 +1,331 @@ +metricsPath); + $writer->setMetadata( + commitId: $this->commitId, + driverId: $this->driverConfig->driverId, + primaryDriverVersion: $this->probeDriverVersion(), + secondaryDriverId: $this->driverConfig->secondaryDriverId(), + ); + + foreach ($this->workloadConfig->phases as $phase) { + $collector = $this->runPhase($phase); + $writer->writePhaseResults($phase->id, 'COMPLETED', $phase->connections, $collector); + } + } + + private function mode(): string + { + if ($this->concurrencyMode !== null) { + return $this->concurrencyMode; + } + + // Recording driver is server-free and cheap — run inline. + if ($this->driverConfig->driverId === 'recording') { + return 'inline'; + } + + return function_exists('pcntl_fork') ? 'process' : 'inline'; + } + + private function runPhase(PhaseConfig $phase): Collector + { + $collector = new Collector(); + $collector->start(); + + $workerCount = max(1, min($phase->connections, self::MAX_WORKERS)); + + if ($this->mode() === 'process' && function_exists('pcntl_fork')) { + $this->runPhaseMultiProcess($phase, $workerCount, $collector); + } else { + $this->runPhaseInline($phase, $workerCount, $collector); + } + + $collector->stop(); + + return $collector; + } + + // --- Multi-process execution ------------------------------------------- + + private function runPhaseMultiProcess(PhaseConfig $phase, int $workerCount, Collector $collector): void + { + /** @var array $parentEnds */ + $parentEnds = []; + /** @var array $children pid => worker index */ + $children = []; + + for ($i = 0; $i < $workerCount; $i++) { + $pair = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP); + if ($pair === false) { + throw new \RuntimeException('stream_socket_pair failed'); + } + [$parentEnd, $childEnd] = $pair; + + $pid = pcntl_fork(); + if ($pid === -1) { + throw new \RuntimeException('pcntl_fork failed'); + } + + if ($pid === 0) { + // CHILD: connect after fork, run slice, write partial, exit. + fclose($parentEnd); + $this->runWorkerAndReport($phase, $workerCount, $i, $childEnd); + fclose($childEnd); + exit(0); + } + + fclose($childEnd); + $parentEnds[$i] = $parentEnd; + $children[$pid] = $i; + } + + // Collect partial metrics from each worker. + foreach ($parentEnds as $fh) { + $payload = stream_get_contents($fh); + fclose($fh); + if ($payload === false || $payload === '') { + continue; + } + $this->mergePartial($collector, $payload); + } + + // Reap children. + foreach (array_keys($children) as $pid) { + $status = 0; + pcntl_waitpid($pid, $status); + } + } + + /** + * @param resource $childEnd + */ + private function runWorkerAndReport(PhaseConfig $phase, int $workerCount, int $workerIndex, $childEnd): void + { + $workerCollector = $this->runWorker($phase, $workerCount, $workerIndex); + fwrite($childEnd, $this->serializeCollector($workerCollector)); + } + + // --- Inline execution (fallback / recording) --------------------------- + + private function runPhaseInline(PhaseConfig $phase, int $workerCount, Collector $collector): void + { + for ($i = 0; $i < $workerCount; $i++) { + $workerCollector = $this->runWorker($phase, $workerCount, $i); + $collector->mergeFrom($workerCollector); + } + } + + // --- Shared worker loop ------------------------------------------------ + + private function runWorker(PhaseConfig $phase, int $workerCount, int $workerIndex): Collector + { + $collector = new Collector(); + + $client = ClientFactory::createAndConnect($this->host, $this->port, $this->driverConfig); + try { + $commands = CommandFactory::createAll($phase->commands); + $selector = new CommandSelector($commands); + $keyGen = $this->keyGeneratorForWorker($phase->keyspace, $workerIndex); + + // Divide the phase-level rate limit across workers. + $rps = $phase->hasRpsLimit() ? max(1, intdiv($phase->rpsLimit, $workerCount)) : -1; + $limiter = RateLimiter::create($rps); + + $this->runWarmup($client, $commands, $keyGen, $phase->warmupRequests, $workerCount, $workerIndex); + + $target = $this->workerRequestTarget($phase, $workerCount, $workerIndex); + $deadline = $phase->completion->isDurationBased() + ? microtime(true) + $phase->completion->durationSeconds() + : null; + + $done = 0; + while (true) { + if ($target !== null && $done >= $target) { + break; + } + if ($deadline !== null && microtime(true) >= $deadline) { + break; + } + + $limiter?->acquire(); + $command = $selector->select(); + $collector->record($command->execute($client, $keyGen)); + $done++; + } + } finally { + $client->close(); + } + + return $collector; + } + + private function runWarmup( + BenchmarkClient $client, + array $commands, + KeyGenerator $keyGen, + int $warmupRequests, + int $workerCount, + int $workerIndex, + ): void { + if ($warmupRequests <= 0 || $commands === []) { + return; + } + $share = intdiv($warmupRequests, $workerCount); + if ($workerIndex < ($warmupRequests % $workerCount)) { + $share++; + } + $selector = new CommandSelector($commands); + for ($i = 0; $i < $share; $i++) { + $selector->select()->execute($client, $keyGen); + } + } + + /** + * Per-worker request target for request-based completion, split evenly with + * the remainder distributed to the first workers (matches Java's forkForThread). + */ + private function workerRequestTarget(PhaseConfig $phase, int $workerCount, int $workerIndex): ?int + { + if (!$phase->completion->isRequestBased()) { + return null; + } + $total = $phase->completion->totalRequests(); + $share = intdiv($total, $workerCount); + if ($workerIndex < ($total % $workerCount)) { + $share++; + } + + return $share; + } + + /** + * uniform_rand: seed per worker (seed + index) for reproducible-yet-distinct + * sequences. sequential_int: shared config (each worker walks the keyspace). + */ + private function keyGeneratorForWorker(KeyspaceConfig $keyspace, int $workerIndex): KeyGenerator + { + if ($keyspace->isUniformRand()) { + return KeyGenerator::createWithSeed($keyspace, $keyspace->seedValue() + $workerIndex); + } + + return KeyGenerator::create($keyspace); + } + + // --- Cross-process metrics serialization ------------------------------- + + /** + * Serialize a collector's totals + per-command counts + histogram counts to + * JSON. Histograms are sent as sparse index=>count maps so the parent can + * reconstruct an identical HdrHistogram and merge losslessly. + */ + private function serializeCollector(Collector $collector): string + { + $commands = []; + foreach ($collector->allMetrics() as $name => $cmd) { + $histogram = $cmd->histogram(); + $counts = []; + if ($histogram !== null) { + $len = $histogram->relevantLength(); + for ($i = 0; $i < $len; $i++) { + $c = $histogram->rawCountAt($i); + if ($c > 0) { + $counts[$i] = $c; + } + } + } + $commands[$name] = [ + 'requests' => $cmd->requests(), + 'errors' => $cmd->errors(), + 'counts' => $counts, + ]; + } + + return json_encode([ + 'total_requests' => $collector->totalRequests(), + 'total_errors' => $collector->totalErrors(), + 'commands' => $commands, + ], JSON_THROW_ON_ERROR) . "\n"; + } + + private function mergePartial(Collector $collector, string $payload): void + { + // A worker writes a single JSON line; guard against partial reads. + $line = trim($payload); + if ($line === '') { + return; + } + + /** @var array{total_requests:int,total_errors:int,commands:array}>} $data */ + $data = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + + $partial = new Collector(); + // Rebuild a collector via a temporary histogram-backed merge. + foreach ($data['commands'] as $name => $cmd) { + $histogram = new HdrHistogram(1, 600_000_000, 3); + foreach ($cmd['counts'] as $index => $count) { + $histogram->recordValueWithCount($histogram->valueFromIndex((int) $index), (int) $count); + } + $partial->ingestCommand($name, $cmd['requests'], $cmd['errors'], $histogram); + } + $partial->ingestTotals($data['total_requests'], $data['total_errors']); + + $collector->mergeFrom($partial); + } + + private function probeDriverVersion(): string + { + try { + $client = ClientFactory::create((string) $this->driverConfig->driverId); + + return $client->driverVersion(); + } catch (\Throwable) { + return 'unknown'; + } + } +} diff --git a/php/src/Engine/CommandSelector.php b/php/src/Engine/CommandSelector.php new file mode 100644 index 0000000..608d139 --- /dev/null +++ b/php/src/Engine/CommandSelector.php @@ -0,0 +1,65 @@ + */ + private readonly array $commands; + + /** @var list */ + private readonly array $cumulativeWeights; + + /** + * @param list $commands + */ + public function __construct(array $commands) + { + $this->commands = $commands; + $this->cumulativeWeights = self::buildCumulativeWeights($commands); + } + + public function select(): Command + { + // mt_rand()/mt_getrandmax() gives a float in [0, 1]. + $r = mt_rand() / mt_getrandmax(); + foreach ($this->cumulativeWeights as $index => $threshold) { + if ($r <= $threshold) { + return $this->commands[$index]; + } + } + + return $this->commands[array_key_last($this->commands)]; + } + + /** + * @param list $commands + * @return list + */ + private static function buildCumulativeWeights(array $commands): array + { + $totalWeight = 0.0; + foreach ($commands as $cmd) { + $totalWeight += $cmd->weight; + } + if ($totalWeight === 0.0) { + $totalWeight = 1.0; + } + + $cumulative = []; + $sum = 0.0; + foreach ($commands as $cmd) { + $sum += $cmd->weight / $totalWeight; + $cumulative[] = $sum; + } + + return $cumulative; + } +} diff --git a/php/src/Engine/JavaRandom.php b/php/src/Engine/JavaRandom.php new file mode 100644 index 0000000..0018566 --- /dev/null +++ b/php/src/Engine/JavaRandom.php @@ -0,0 +1,103 @@ +seed = $this->initialScramble($seed); + } + + /** + * Generate the next random integer in range [0, bound). + * Matches Java's Random.nextInt(int bound). + */ + public function nextInt(int $bound): int + { + if ($bound <= 0) { + throw new InvalidArgumentException('bound must be positive'); + } + + // Special case for powers of two. + if (($bound & -$bound) === $bound) { + return (int) (($bound * $this->nextBits(31)) >> 31); + } + + // General case - rejection sampling to avoid modulo bias. + while (true) { + $bits = $this->nextBits(31); + $val = $bits % $bound; + if ($bits - $val + ($bound - 1) >= 0) { + return $val; + } + } + } + + /** + * Reset the generator with a new seed. + */ + public function setSeed(int $seed): void + { + $this->seed = $this->initialScramble($seed); + } + + private function initialScramble(int $seed): int + { + return ($seed ^ self::MULTIPLIER) & self::MASK; + } + + /** + * Generate the next `bits` random bits (1-32). + * + * Java's LCG relies on 64-bit integer overflow: it computes the full + * product `seed * MULTIPLIER` and keeps the low 48 bits. In PHP a direct + * multiply overflows the 64-bit signed int and silently becomes a float, + * corrupting the low bits. We therefore compute `(seed * MULTIPLIER) mod 2^48` + * using a 24-bit split so every partial product stays well under 2^63. + * + * seed = hi * 2^24 + lo (hi, lo < 2^24) + * seed * M ≡ (lo*M) + ((hi*M mod 2^24) << 24) (mod 2^48) + * + * With M = 0x5DEECE66D (< 2^35), both lo*M and hi*M are < 2^59 — no overflow. + */ + private function nextBits(int $bits): int + { + $seed = $this->seed; + $lo = $seed & 0xFFFFFF; // low 24 bits + $hi = ($seed >> 24) & 0xFFFFFF; // next 24 bits + + $product = (($lo * self::MULTIPLIER) + + ((($hi * self::MULTIPLIER) & 0xFFFFFF) << 24)) & self::MASK; + + $this->seed = ($product + self::ADDEND) & self::MASK; + + return $this->seed >> (48 - $bits); + } +} diff --git a/php/src/Engine/KeyGenerator.php b/php/src/Engine/KeyGenerator.php new file mode 100644 index 0000000..feb7574 --- /dev/null +++ b/php/src/Engine/KeyGenerator.php @@ -0,0 +1,95 @@ +keyPrefix = $config->effectiveKeyPrefix(); + $this->keySizeBytes = $config->keySizeBytes; + $this->keysCount = $config->keysCount; + $this->seed = $seedOverride ?? $config->seedValue(); + $this->sequential = $config->isSequentialInt(); + $this->random = new JavaRandom($this->seed); + } + + public static function create(KeyspaceConfig $config): self + { + return new self($config); + } + + public static function createWithSeed(KeyspaceConfig $config, int $seed): self + { + return new self($config, $seed); + } + + /** + * Generate the next key. + */ + public function nextKey(): string + { + if ($this->sequential) { + $keyIndex = $this->sequentialCounter; + $this->sequentialCounter++; + } else { + $keyIndex = $this->random->nextInt($this->keysCount); + } + + $keyIndex %= $this->keysCount; + + return $this->formatKey($keyIndex); + } + + /** + * Reset the generator to its initial state. + */ + public function reset(): void + { + $this->sequentialCounter = 0; + $this->random->setSeed($this->seed); + } + + /** + * Format a key index into a full key string. + * Matches Java's String.format("%0Nd", keyIndex): zero-padded so that + * prefix + number is approximately key_size_bytes wide (minimum 1 digit). + */ + private function formatKey(int $keyIndex): string + { + $paddingWidth = max($this->keySizeBytes - strlen($this->keyPrefix), 1); + + return $this->keyPrefix . sprintf('%0' . $paddingWidth . 'd', $keyIndex); + } +} diff --git a/php/src/Engine/RateLimiter.php b/php/src/Engine/RateLimiter.php new file mode 100644 index 0000000..e7df884 --- /dev/null +++ b/php/src/Engine/RateLimiter.php @@ -0,0 +1,80 @@ +intervalNanos = intdiv(1_000_000_000, $ratePerSecond); + $this->nextAllowedNanos = self::monotonicNanos(); + } + + /** + * @return self|null null if the rate is unlimited (<= 0) + */ + public static function create(int $ratePerSecond): ?self + { + if ($ratePerSecond <= 0) { + return null; + } + + return new self($ratePerSecond); + } + + /** + * Block until one operation is allowed. + */ + public function acquire(): void + { + while (true) { + $now = self::monotonicNanos(); + if ($now >= $this->nextAllowedNanos) { + $this->nextAllowedNanos += $this->intervalNanos; + + return; + } + + $waitNanos = $this->nextAllowedNanos - $now; + // usleep takes microseconds. + $micros = intdiv($waitNanos, 1000); + if ($micros > 0) { + usleep($micros); + } + } + } + + /** + * Try to acquire without blocking. + */ + public function tryAcquire(): bool + { + $now = self::monotonicNanos(); + if ($now < $this->nextAllowedNanos) { + return false; + } + $this->nextAllowedNanos += $this->intervalNanos; + + return true; + } + + private static function monotonicNanos(): int + { + return (int) hrtime(true); + } +} diff --git a/php/src/Metrics/Collector.php b/php/src/Metrics/Collector.php new file mode 100644 index 0000000..654be4d --- /dev/null +++ b/php/src/Metrics/Collector.php @@ -0,0 +1,204 @@ +requests++; + if ($result->success) { + $latency = min($result->latencyMicros, 600_000_000); + $this->histogram ??= new HdrHistogram(1, 600_000_000, 3); + $this->histogram->record($latency); + } else { + $this->errors++; + } + } + + public function mergeFrom(self $other): void + { + $this->requests += $other->requests; + $this->errors += $other->errors; + if ($other->histogram !== null && $other->histogram->totalCount() > 0) { + $this->histogram ??= new HdrHistogram(1, 600_000_000, 3); + $this->histogram->merge($other->histogram); + } + } + + /** + * Ingest raw counts plus a fully-built histogram (cross-process reconstruction). + */ + public function ingest(int $requests, int $errors, HdrHistogram $histogram): void + { + $this->requests += $requests; + $this->errors += $errors; + if ($histogram->totalCount() > 0) { + $this->histogram ??= new HdrHistogram(1, 600_000_000, 3); + $this->histogram->merge($histogram); + } + } + + public function requests(): int + { + return $this->requests; + } + + public function errors(): int + { + return $this->errors; + } + + public function histogram(): ?HdrHistogram + { + return $this->histogram; + } + + public function count(): int + { + return $this->histogram?->totalCount() ?? 0; + } + + public function min(): int + { + return $this->histogram?->min() ?? 0; + } + + public function max(): int + { + return $this->histogram?->max() ?? 0; + } + + public function percentile(float $p): int + { + return $this->histogram?->valueAtPercentile($p) ?? 0; + } +} + +/** + * Collects benchmark metrics. Used both inside a worker (record()) and in the + * parent to merge partial collectors from all forked workers. + */ +final class Collector +{ + /** @var array */ + private array $commandMetrics = []; + private int $totalRequests = 0; + private int $totalErrors = 0; + private ?float $startTime = null; + private ?float $endTime = null; + + public function start(): void + { + $this->startTime = microtime(true); + } + + public function stop(): void + { + $this->endTime = microtime(true); + } + + public function record(CommandResult $result): void + { + $this->totalRequests++; + if (!$result->success) { + $this->totalErrors++; + } + + $metrics = $this->commandMetrics[$result->commandName] + ??= new CommandMetrics($result->commandName); + $metrics->record($result); + } + + /** + * Merge another collector's per-command metrics and totals into this one. + */ + public function mergeFrom(self $other): void + { + $this->totalRequests += $other->totalRequests; + $this->totalErrors += $other->totalErrors; + + foreach ($other->commandMetrics as $name => $metrics) { + $merged = $this->commandMetrics[$name] ??= new CommandMetrics($name); + $merged->mergeFrom($metrics); + } + } + + /** + * Ingest a reconstructed command's counts + histogram (used when rebuilding a + * partial collector from a forked worker's serialized payload). + */ + public function ingestCommand(string $name, int $requests, int $errors, HdrHistogram $histogram): void + { + $metrics = $this->commandMetrics[$name] ??= new CommandMetrics($name); + $metrics->ingest($requests, $errors, $histogram); + } + + public function ingestTotals(int $requests, int $errors): void + { + $this->totalRequests += $requests; + $this->totalErrors += $errors; + } + + public function totalRequests(): int + { + return $this->totalRequests; + } + + public function totalErrors(): int + { + return $this->totalErrors; + } + + public function startTime(): ?float + { + return $this->startTime; + } + + public function endTime(): ?float + { + return $this->endTime; + } + + public function setStartTime(float $t): void + { + $this->startTime = $t; + } + + public function setEndTime(float $t): void + { + $this->endTime = $t; + } + + public function durationMillis(): int + { + if ($this->startTime === null || $this->endTime === null) { + return 0; + } + + return (int) (($this->endTime - $this->startTime) * 1000); + } + + /** + * @return array + */ + public function allMetrics(): array + { + return $this->commandMetrics; + } +} diff --git a/php/src/Metrics/HdrEncoder.php b/php/src/Metrics/HdrEncoder.php new file mode 100644 index 0000000..d497fc7 --- /dev/null +++ b/php/src/Metrics/HdrEncoder.php @@ -0,0 +1,155 @@ +significantFigures; + $lowest = $histogram->lowestTrackableValue; + $highest = $histogram->highestTrackableValue; + $conversionRatioBits = self::doubleToLongBits(1.0); + + $countsBytes = self::encodeCounts($histogram); + + // payload_len = everything after the payload_len field itself: + // normalizing(4) + sigfigs(4) + lowest(8) + highest(8) + ratio(8) + counts + $payloadLen = 4 + 4 + 8 + 8 + 8 + strlen($countsBytes); + + $header = pack('N4', self::V2_ENCODING_COOKIE, $payloadLen, $normalizingOffset, $sigFigs); + $int64Fields = self::packInt64BE($lowest) + . self::packInt64BE($highest) + . self::packInt64BE($conversionRatioBits); + + return $header . $int64Fields . $countsBytes; + } + + private static function encodeCounts(HdrHistogram $histogram): string + { + $relevantLength = $histogram->relevantLength(); + $result = ''; + $index = 0; + + while ($index < $relevantLength) { + $count = $histogram->rawCountAt($index); + if ($count === 0) { + $zeros = 1; + while (($index + $zeros) < $relevantLength && $histogram->rawCountAt($index + $zeros) === 0) { + $zeros++; + } + $result .= self::encodeZigZag(-$zeros); + $index += $zeros; + } else { + $result .= self::encodeZigZag($count); + $index++; + } + } + + return $result; + } + + /** + * ZigZag + LEB128 encode a signed 64-bit integer. + */ + private static function encodeZigZag(int $value): string + { + // ZigZag: (value << 1) ^ (value >> 63), using arithmetic shift semantics. + $zz = ($value << 1) ^ ($value >> 63); + + $result = ''; + // Process as unsigned 64-bit via logical right shift. + while (true) { + if (($zz & ~0x7F) === 0) { + $result .= chr($zz & 0x7F); + break; + } + $result .= chr(($zz & 0x7F) | 0x80); + $zz = self::logicalShiftRight($zz, 7); + } + + return $result; + } + + /** + * Logical (unsigned) right shift for 64-bit ints in PHP. + */ + private static function logicalShiftRight(int $value, int $bits): int + { + if ($bits === 0) { + return $value; + } + + // Shift then clear the top `bits` sign-extended bits. + return ($value >> $bits) & (PHP_INT_MAX >> ($bits - 1)); + } + + private static function doubleToLongBits(float $value): int + { + // Pack as big-endian double, unpack as signed big-endian int64. + $packed = pack('E', $value); // 'E' = big-endian double + /** @var array{1:int} $unpacked */ + $unpacked = unpack('J', $packed); // 'J' = big-endian unsigned int64 + + return $unpacked[1]; + } + + private static function packInt64BE(int $value): string + { + // 'J' handles the full 64-bit range; PHP ints are 64-bit signed. + return pack('J', $value); + } +} diff --git a/php/src/Metrics/HdrHistogram.php b/php/src/Metrics/HdrHistogram.php new file mode 100644 index 0000000..dc753b4 --- /dev/null +++ b/php/src/Metrics/HdrHistogram.php @@ -0,0 +1,255 @@ + sparse index => count */ + private array $counts = []; + + private int $totalCount = 0; + private int $minNonZeroValue = PHP_INT_MAX; + private int $maxValue = 0; + + public function __construct( + int $lowestTrackableValue = 1, + int $highestTrackableValue = 600_000_000, + int $significantFigures = 3, + ) { + $this->lowestTrackableValue = $lowestTrackableValue; + $this->highestTrackableValue = $highestTrackableValue; + $this->significantFigures = $significantFigures; + + $largestValueWithSingleUnitResolution = 2 * (int) (10 ** $significantFigures); + $subBucketCountMagnitude = (int) ceil(log($largestValueWithSingleUnitResolution, 2)); + $this->subBucketHalfCountMagnitude = max($subBucketCountMagnitude, 1) - 1; + + $this->unitMagnitude = (int) floor(log($lowestTrackableValue, 2)); + + $this->subBucketCount = 2 ** ($this->subBucketHalfCountMagnitude + 1); + $this->subBucketHalfCount = $this->subBucketCount >> 1; + $this->subBucketMask = ($this->subBucketCount - 1) << $this->unitMagnitude; + + // Number of buckets needed to cover the highest trackable value. + $smallestUntrackableValue = $this->subBucketCount << $this->unitMagnitude; + $bucketsNeeded = 1; + while ($smallestUntrackableValue < $highestTrackableValue) { + if ($smallestUntrackableValue > (PHP_INT_MAX >> 1)) { + $bucketsNeeded++; + break; + } + $smallestUntrackableValue <<= 1; + $bucketsNeeded++; + } + $this->bucketCount = $bucketsNeeded; + $this->countsLen = ($this->bucketCount + 1) * ($this->subBucketCount >> 1); + } + + public function record(int $value): void + { + if ($value < 0) { + return; + } + $index = $this->countsIndexFor($value); + $this->counts[$index] = ($this->counts[$index] ?? 0) + 1; + $this->totalCount++; + + if ($value > $this->maxValue) { + $this->maxValue = $value; + } + if ($value !== 0 && $value < $this->minNonZeroValue) { + $this->minNonZeroValue = $value; + } + } + + public function recordValueWithCount(int $value, int $count): void + { + if ($value < 0 || $count <= 0) { + return; + } + $index = $this->countsIndexFor($value); + $this->counts[$index] = ($this->counts[$index] ?? 0) + $count; + $this->totalCount += $count; + + if ($value > $this->maxValue) { + $this->maxValue = $value; + } + if ($value !== 0 && $value < $this->minNonZeroValue) { + $this->minNonZeroValue = $value; + } + } + + public function merge(self $other): void + { + for ($i = 0; $i < $other->countsLen; $i++) { + $count = $other->rawCountAt($i); + if ($count > 0) { + $value = $other->valueFromIndex($i); + $this->recordValueWithCount($value, $count); + } + } + } + + public function totalCount(): int + { + return $this->totalCount; + } + + public function min(): int + { + if ($this->totalCount === 0) { + return 0; + } + + // If only zero-valued samples were recorded, min is 0. + return $this->minNonZeroValue === PHP_INT_MAX ? 0 : $this->minNonZeroValue; + } + + public function max(): int + { + return $this->maxValue; + } + + public function valueAtPercentile(float $percentile): int + { + if ($this->totalCount === 0) { + return 0; + } + + $requestedPercentile = min(max($percentile, 0.0), 100.0); + $countAtPercentile = (int) ceil(($requestedPercentile / 100.0) * $this->totalCount); + $countAtPercentile = max($countAtPercentile, 1); + + $total = 0; + for ($i = 0; $i < $this->countsLen; $i++) { + $total += $this->rawCountAt($i); + if ($total >= $countAtPercentile) { + $valueAtIndex = $this->valueFromIndex($i); + + return $this->highestEquivalentValue($valueAtIndex); + } + } + + return $this->maxValue; + } + + public function rawCountAt(int $index): int + { + return $this->counts[$index] ?? 0; + } + + /** + * Highest index with a non-zero count, plus one (the "relevant length"). + */ + public function relevantLength(): int + { + if ($this->counts === []) { + return 0; + } + + return max(array_keys($this->counts)) + 1; + } + + // --- HdrHistogram index math (mirrors the Java implementation) --- + + private function countsIndexFor(int $value): int + { + $bucketIndex = $this->bucketIndexFor($value); + $subBucketIndex = $this->subBucketIndexFor($value, $bucketIndex); + + return $this->countsIndex($bucketIndex, $subBucketIndex); + } + + private function bucketIndexFor(int $value): int + { + $pow2ceiling = self::bitLength($value | $this->subBucketMask); + + return $pow2ceiling - $this->unitMagnitude - ($this->subBucketHalfCountMagnitude + 1); + } + + private function subBucketIndexFor(int $value, int $bucketIndex): int + { + return $value >> ($bucketIndex + $this->unitMagnitude); + } + + private function countsIndex(int $bucketIndex, int $subBucketIndex): int + { + $bucketBaseIndex = ($bucketIndex + 1) << $this->subBucketHalfCountMagnitude; + $offsetInBucket = $subBucketIndex - $this->subBucketHalfCount; + + return $bucketBaseIndex + $offsetInBucket; + } + + public function valueFromIndex(int $index): int + { + $bucketIndex = ($index >> $this->subBucketHalfCountMagnitude) - 1; + $subBucketIndex = ($index & ($this->subBucketHalfCount - 1)) + $this->subBucketHalfCount; + + if ($bucketIndex < 0) { + $subBucketIndex -= $this->subBucketHalfCount; + $bucketIndex = 0; + } + + return $subBucketIndex << ($bucketIndex + $this->unitMagnitude); + } + + private function highestEquivalentValue(int $value): int + { + return $this->nextNonEquivalentValue($value) - 1; + } + + private function nextNonEquivalentValue(int $value): int + { + return $this->lowestEquivalentValue($value) + $this->sizeOfEquivalentValueRange($value); + } + + private function lowestEquivalentValue(int $value): int + { + $bucketIndex = $this->bucketIndexFor($value); + $subBucketIndex = $this->subBucketIndexFor($value, $bucketIndex); + + return $subBucketIndex << ($bucketIndex + $this->unitMagnitude); + } + + private function sizeOfEquivalentValueRange(int $value): int + { + $bucketIndex = $this->bucketIndexFor($value); + + return 1 << ($this->unitMagnitude + $bucketIndex); + } + + private static function bitLength(int $value): int + { + $length = 0; + while ($value > 0) { + $value >>= 1; + $length++; + } + + return $length; + } +} diff --git a/php/src/Metrics/NdjsonWriter.php b/php/src/Metrics/NdjsonWriter.php new file mode 100644 index 0000000..151f8c3 --- /dev/null +++ b/php/src/Metrics/NdjsonWriter.php @@ -0,0 +1,156 @@ +commitId = $commitId; + $this->driverId = $driverId; + $this->primaryDriverVersion = $primaryDriverVersion; + $this->secondaryDriverId = $secondaryDriverId; + $this->secondaryDriverVersion = $secondaryDriverVersion; + } + + public function writePhaseResults(string $phaseId, string $status, int $connections, Collector $collector): void + { + $dir = dirname($this->outputPath); + if (!is_dir($dir) && !mkdir($dir, 0o777, true) && !is_dir($dir)) { + throw new RuntimeException("Cannot create output directory: {$dir}"); + } + + $json = $this->buildPhaseJson($phaseId, $status, $connections, $collector); + $line = json_encode($json, JSON_THROW_ON_ERROR | JSON_UNESCAPED_SLASHES); + + file_put_contents($this->outputPath, $line . "\n", FILE_APPEND | LOCK_EX); + } + + /** + * @return array + */ + private function buildPhaseJson(string $phaseId, string $status, int $connections, Collector $collector): array + { + $result = []; + + if ($this->commitId !== null || $this->driverId !== null) { + $metadata = []; + if ($this->commitId !== null) { + $metadata['commit_id'] = $this->commitId; + } + $metadata['timestamp'] = gmdate('Y-m-d\TH:i:s\Z'); + if ($this->driverId !== null) { + $metadata['driver_id'] = $this->driverId; + } + if ($this->primaryDriverVersion !== null) { + $metadata['primary_driver_version'] = $this->primaryDriverVersion; + } + if ($this->secondaryDriverId !== null) { + $metadata['secondary_driver_id'] = $this->secondaryDriverId; + } + if ($this->secondaryDriverVersion !== null) { + $metadata['secondary_driver_version'] = $this->secondaryDriverVersion; + } + $result['metadata'] = $metadata; + } + + $result['phase'] = [ + 'id' => $phaseId, + 'status' => $status, + 'start_timestamp' => self::iso8601($collector->startTime()), + 'finish_timestamp' => self::iso8601($collector->endTime()), + 'duration_ms' => $collector->durationMillis(), + 'connections' => $connections, + ]; + + $result['totals'] = [ + 'requests' => $collector->totalRequests(), + 'errors' => $collector->totalErrors(), + ]; + + $result['metrics'] = $this->buildCommandMetrics($collector); + + return $result; + } + + /** + * @return array + */ + private function buildCommandMetrics(Collector $collector): array + { + $metrics = []; + + foreach ($collector->allMetrics() as $name => $cmd) { + $data = [ + 'requests' => $cmd->requests(), + 'errors' => $cmd->errors(), + 'latency' => [ + 'unit' => 'us', + 'count' => $cmd->count(), + 'summary' => [ + 'min' => $cmd->min(), + 'p50' => $cmd->percentile(50), + 'p95' => $cmd->percentile(95), + 'p99' => $cmd->percentile(99), + 'p999' => $cmd->percentile(99.9), + 'max' => $cmd->max(), + ], + ], + ]; + + $histogram = $cmd->histogram(); + if ($histogram !== null) { + $data['latency']['hdr'] = [ + 'format' => 'hdr', + 'sigfig' => 3, + 'payload_b64' => self::encodeHistogram($histogram), + ]; + } + + $metrics[$name] = $data; + } + + return $metrics; + } + + private static function encodeHistogram(HdrHistogram $histogram): string + { + try { + return HdrEncoder::encodeCompressedBase64($histogram); + } catch (\Throwable) { + return ''; + } + } + + private static function iso8601(?float $time): ?string + { + if ($time === null) { + return null; + } + + return gmdate('Y-m-d\TH:i:s\Z', (int) $time); + } +} diff --git a/php/src/Version.php b/php/src/Version.php new file mode 100644 index 0000000..71610fc --- /dev/null +++ b/php/src/Version.php @@ -0,0 +1,10 @@ +metricsPath = sys_get_temp_dir() . '/resp_bench_php_test_' . uniqid('', true) . '.ndjson'; + } + + protected function tearDown(): void + { + if ($this->metricsPath !== '' && is_file($this->metricsPath)) { + unlink($this->metricsPath); + } + } + + private function runWorkload(string $mode): array + { + $fixtures = __DIR__ . '/../fixtures'; + $driver = Loader::loadDriverConfig($fixtures . '/recording-driver.json'); + $workload = Loader::loadWorkloadConfig($fixtures . '/smoke-workload.json'); + + $engine = new Benchmark( + host: 'localhost', + port: 6379, + driverConfig: $driver, + workloadConfig: $workload, + metricsPath: $this->metricsPath, + commitId: 'testsha', + concurrencyMode: $mode, + ); + $engine->run(); + + $lines = array_values(array_filter(explode("\n", (string) file_get_contents($this->metricsPath)))); + + return array_map( + static fn (string $line): array => json_decode($line, true, 512, JSON_THROW_ON_ERROR), + $lines, + ); + } + + public function testInlineModeProducesValidNdjson(): void + { + $phases = $this->runWorkload('inline'); + $this->assertSchema($phases); + } + + public function testProcessModeProducesValidNdjson(): void + { + if (!function_exists('pcntl_fork')) { + self::markTestSkipped('pcntl not available'); + } + + $phases = $this->runWorkload('process'); + $this->assertSchema($phases); + } + + public function testInlineAndProcessAgreeOnTotals(): void + { + if (!function_exists('pcntl_fork')) { + self::markTestSkipped('pcntl not available'); + } + + $inline = $this->runWorkload('inline'); + // Reset output between runs. + unlink($this->metricsPath); + $process = $this->runWorkload('process'); + + // Totals are deterministic (request-based completion), independent of mode. + foreach ([0, 1] as $i) { + self::assertSame( + $inline[$i]['totals']['requests'], + $process[$i]['totals']['requests'], + "Phase {$i} request totals differ between modes", + ); + } + } + + private function assertSchema(array $phases): void + { + self::assertCount(2, $phases, 'Expected two phases (WARMUP, STEADY)'); + + // Phase 0: WARMUP, 400 requests, all SET. + $warmup = $phases[0]; + self::assertSame('WARMUP', $warmup['phase']['id']); + self::assertSame('COMPLETED', $warmup['phase']['status']); + self::assertSame(400, $warmup['totals']['requests']); + self::assertSame(0, $warmup['totals']['errors']); + self::assertArrayHasKey('SET', $warmup['metrics']); + + // Metadata + self::assertSame('testsha', $warmup['metadata']['commit_id']); + self::assertSame('recording', $warmup['metadata']['driver_id']); + + // Phase 1: STEADY, 1000 requests total, GET + SET. + $steady = $phases[1]; + self::assertSame('STEADY', $steady['phase']['id']); + self::assertSame(1000, $steady['totals']['requests']); + self::assertArrayHasKey('GET', $steady['metrics']); + self::assertArrayHasKey('SET', $steady['metrics']); + + // GET + SET request counts sum to the total. + $sum = $steady['metrics']['GET']['requests'] + $steady['metrics']['SET']['requests']; + self::assertSame(1000, $sum); + + // Latency block schema. + $latency = $steady['metrics']['GET']['latency']; + self::assertSame('us', $latency['unit']); + self::assertArrayHasKey('summary', $latency); + foreach (['min', 'p50', 'p95', 'p99', 'p999', 'max'] as $k) { + self::assertArrayHasKey($k, $latency['summary']); + self::assertIsInt($latency['summary'][$k]); + } + self::assertSame('hdr', $latency['hdr']['format']); + self::assertSame(3, $latency['hdr']['sigfig']); + self::assertNotSame('', $latency['hdr']['payload_b64']); + } +} diff --git a/php/tests/Unit/CommandSelectorTest.php b/php/tests/Unit/CommandSelectorTest.php new file mode 100644 index 0000000..9ca396e --- /dev/null +++ b/php/tests/Unit/CommandSelectorTest.php @@ -0,0 +1,43 @@ +select()->name); + } + } + + public function testWeightedDistributionApproximatelyHolds(): void + { + $commands = CommandFactory::createAll([ + new CommandConfig('get', 0.8), + new CommandConfig('set', 0.2, 64), + ]); + $selector = new CommandSelector($commands); + + $counts = ['GET' => 0, 'SET' => 0]; + $n = 20000; + for ($i = 0; $i < $n; $i++) { + $counts[$selector->select()->name]++; + } + + $getRatio = $counts['GET'] / $n; + // Expect ~0.8; allow +/- 0.05 for randomness. + self::assertGreaterThan(0.72, $getRatio); + self::assertLessThan(0.88, $getRatio); + } +} diff --git a/php/tests/Unit/ConfigLoaderTest.php b/php/tests/Unit/ConfigLoaderTest.php new file mode 100644 index 0000000..5edb041 --- /dev/null +++ b/php/tests/Unit/ConfigLoaderTest.php @@ -0,0 +1,107 @@ +driverId); + self::assertSame('standalone', $config->mode); + self::assertTrue($config->isStandalone()); + self::assertSame(8, $config->specificDriverConfig['pool_size']); + } + + public function testDriverConfigDefaults(): void + { + $config = Loader::parseDriverConfigString('{}'); + self::assertSame('1.0', $config->schemaVersion); + self::assertSame('standalone', $config->mode); + self::assertNull($config->driverId); + self::assertSame([], $config->specificDriverConfig); + } + + public function testClusterMode(): void + { + $config = Loader::parseDriverConfigString('{"driver_id":"x","mode":"cluster"}'); + self::assertTrue($config->isCluster()); + self::assertFalse($config->isStandalone()); + } + + public function testSecondaryDriverId(): void + { + $json = '{"driver_id":"spring","specific_driver_config":{"secondary_driver_id":"valkey-glide"}}'; + $config = Loader::parseDriverConfigString($json); + self::assertSame('valkey-glide', $config->secondaryDriverId()); + } + + public function testParsesWorkloadConfig(): void + { + $json = <<name()); + self::assertCount(1, $config->phases); + + $phase = $config->phases[0]; + self::assertSame('STEADY', $phase->id); + self::assertSame(16, $phase->connections); + self::assertTrue($phase->hasRpsLimit()); + self::assertSame(5000, $phase->rpsLimit); + self::assertTrue($phase->completion->isRequestBased()); + self::assertSame(100000, $phase->completion->totalRequests()); + + self::assertSame('uniform_rand', $phase->keyspace->generationAlg); + self::assertSame(7, $phase->keyspace->seed); + self::assertSame('k:', $phase->keyspace->keyPrefix); + + self::assertCount(2, $phase->commands); + // Command names are lowercased at config level. + self::assertSame('get', $phase->commands[0]->command); + self::assertEqualsWithDelta(0.7, $phase->commands[0]->weight, 1e-9); + self::assertSame(128, $phase->commands[1]->dataSizeBytes); + } + + public function testWorkloadDefaults(): void + { + $json = '{"phases":[{"id":"P","connections":1,"completion":{"type":"requests","requests":1},"keyspace":{"keys_count":1},"commands":[{"command":"ping","weight":1.0}]}]}'; + $config = Loader::parseWorkloadConfigString($json); + $phase = $config->phases[0]; + + self::assertSame(-1, $phase->cpsLimit); + self::assertSame(-1, $phase->rpsLimit); + self::assertFalse($phase->hasRpsLimit()); + self::assertSame(1, $phase->effectivePipelineDepth()); + // keyspace defaults + self::assertSame('bench:', $phase->keyspace->keyPrefix); + self::assertSame(16, $phase->keyspace->keySizeBytes); + self::assertTrue($phase->keyspace->isSequentialInt()); + } +} diff --git a/php/tests/Unit/HdrEncoderTest.php b/php/tests/Unit/HdrEncoderTest.php new file mode 100644 index 0000000..f2f7af0 --- /dev/null +++ b/php/tests/Unit/HdrEncoderTest.php @@ -0,0 +1,84 @@ +totalCount()); + self::assertSame(0, $h->min()); + self::assertSame(0, $h->max()); + self::assertSame(0, $h->valueAtPercentile(50)); + } + + public function testRecordsAndComputesPercentiles(): void + { + $h = new HdrHistogram(1, 600_000_000, 3); + for ($i = 1; $i <= 1000; $i++) { + $h->record($i); + } + + self::assertSame(1000, $h->totalCount()); + self::assertSame(1, $h->min()); + // Values are quantized; assert within HdrHistogram equivalence tolerance. + self::assertEqualsWithDelta(500, $h->valueAtPercentile(50), 5); + self::assertEqualsWithDelta(990, $h->valueAtPercentile(99), 10); + self::assertGreaterThanOrEqual(1000, $h->max()); + } + + public function testCompressedEncodingStructure(): void + { + $h = new HdrHistogram(1, 600_000_000, 3); + foreach ([100, 150, 150, 200, 5000, 12345, 250, 250, 250] as $v) { + $h->record($v); + } + + $raw = HdrEncoder::encodeCompressed($h); + + /** @var array{cookie:int,len:int} $wrapper */ + $wrapper = unpack('Ncookie/Nlen', substr($raw, 0, 8)); + self::assertSame(self::COMPRESSED_COOKIE, $wrapper['cookie']); + self::assertSame(strlen($raw) - 8, $wrapper['len']); + + $payload = gzuncompress(substr($raw, 8)); + self::assertIsString($payload); + + /** @var array{cookie:int,plen:int,norm:int,sig:int} $v2 */ + $v2 = unpack('Ncookie/Nplen/Nnorm/Nsig', substr($payload, 0, 16)); + self::assertSame(self::V2_COOKIE, $v2['cookie']); + self::assertSame(0, $v2['norm']); + self::assertSame(3, $v2['sig']); + + /** @var array{lowest:int,highest:int,ratio:int} $fields */ + $fields = unpack('Jlowest/Jhighest/Jratio', substr($payload, 16, 24)); + self::assertSame(1, $fields['lowest']); + self::assertSame(600_000_000, $fields['highest']); + // IEEE754 bits for 1.0 + self::assertSame(4607182418800017408, $fields['ratio']); + } + + public function testBase64Encoding(): void + { + $h = new HdrHistogram(1, 600_000_000, 3); + $h->record(42); + + $b64 = HdrEncoder::encodeCompressedBase64($h); + $decoded = base64_decode($b64, true); + self::assertIsString($decoded); + + /** @var array{cookie:int} $wrapper */ + $wrapper = unpack('Ncookie', substr($decoded, 0, 4)); + self::assertSame(self::COMPRESSED_COOKIE, $wrapper['cookie']); + } +} diff --git a/php/tests/Unit/JavaRandomTest.php b/php/tests/Unit/JavaRandomTest.php new file mode 100644 index 0000000..a5006a3 --- /dev/null +++ b/php/tests/Unit/JavaRandomTest.php @@ -0,0 +1,112 @@ +nextInt(1000); + $values2[] = $rng2->nextInt(1000); + } + + self::assertSame($values1, $values2); + } + + public function testDifferentSeedsProduceDifferentSequences(): void + { + $rng1 = new JavaRandom(12345); + $rng2 = new JavaRandom(54321); + + $values1 = []; + $values2 = []; + for ($i = 0; $i < 10; $i++) { + $values1[] = $rng1->nextInt(1000); + $values2[] = $rng2->nextInt(1000); + } + + self::assertNotSame($values1, $values2); + } + + public function testSetSeedResetsSequence(): void + { + $rng = new JavaRandom(12345); + + $first = []; + for ($i = 0; $i < 5; $i++) { + $first[] = $rng->nextInt(1000); + } + + $rng->setSeed(12345); + + $second = []; + for ($i = 0; $i < 5; $i++) { + $second[] = $rng->nextInt(1000); + } + + self::assertSame($first, $second); + } + + public function testBoundMustBePositive(): void + { + $rng = new JavaRandom(12345); + $this->expectException(InvalidArgumentException::class); + $rng->nextInt(0); + } + + public function testValuesAreWithinBound(): void + { + $rng = new JavaRandom(12345); + $bound = 100; + for ($i = 0; $i < 100; $i++) { + $value = $rng->nextInt($bound); + self::assertGreaterThanOrEqual(0, $value); + self::assertLessThan($bound, $value); + } + } + + public function testPowerOfTwoBounds(): void + { + $rng = new JavaRandom(12345); + foreach ([2, 4, 8, 16, 32, 64, 128, 256, 512, 1024] as $bound) { + for ($i = 0; $i < 100; $i++) { + $value = $rng->nextInt($bound); + self::assertGreaterThanOrEqual(0, $value); + self::assertLessThan($bound, $value); + } + } + } + + /** + * Cross-language anchor: the first 10 outputs of Java's + * `new Random(0).nextInt(1000)`. These are engine-independent facts about + * java.util.Random and are shared by the Ruby/Python/Node ports. + * + * Verified: the underlying LCG reproduces Java's canonical + * `new Random(0).nextInt()` signed-int sequence + * [-1155484576, -723955400, 1033096058, -1690734402, -1557280266, ...]. + */ + public function testMatchesJavaSeedZeroAnchor(): void + { + $rng = new JavaRandom(0); + $actual = []; + for ($i = 0; $i < 10; $i++) { + $actual[] = $rng->nextInt(1000); + } + + $expected = [360, 948, 29, 447, 515, 53, 491, 761, 719, 854]; + self::assertSame($expected, $actual); + } +} diff --git a/php/tests/Unit/KeyGeneratorTest.php b/php/tests/Unit/KeyGeneratorTest.php new file mode 100644 index 0000000..c11d24c --- /dev/null +++ b/php/tests/Unit/KeyGeneratorTest.php @@ -0,0 +1,129 @@ +nextKey(); + $key2 = $gen->nextKey(); + + self::assertStringStartsWith('test:', $key1); + self::assertStringStartsWith('test:', $key2); + self::assertNotSame($key1, $key2); + } + + public function testSequentialKeysWrapAround(): void + { + $config = new KeyspaceConfig(keysCount: 3, keyPrefix: 'test:'); + $gen = new KeyGenerator($config); + + $keys = []; + for ($i = 0; $i < 6; $i++) { + $keys[] = $gen->nextKey(); + } + + // Keys should wrap: 0, 1, 2, 0, 1, 2 + self::assertSame($keys[0], $keys[3]); + self::assertSame($keys[1], $keys[4]); + self::assertSame($keys[2], $keys[5]); + } + + public function testGeneratesUniformRandomKeys(): void + { + $config = new KeyspaceConfig( + keysCount: 1000, + keyPrefix: 'rand:', + generationAlg: 'uniform_rand', + seed: 12345, + ); + $gen = new KeyGenerator($config); + + $keys = []; + for ($i = 0; $i < 100; $i++) { + $keys[] = $gen->nextKey(); + } + + $unique = array_unique($keys); + self::assertGreaterThan(50, count($unique)); + } + + public function testResetRestartsSequentialCounter(): void + { + $config = new KeyspaceConfig(keysCount: 100, keyPrefix: 'test:'); + $gen = new KeyGenerator($config); + + $first1 = $gen->nextKey(); + $gen->nextKey(); + $gen->reset(); + $first2 = $gen->nextKey(); + + self::assertSame($first1, $first2); + } + + public function testResetRestartsRandomSequence(): void + { + $config = new KeyspaceConfig( + keysCount: 1000, + keyPrefix: 'rand:', + generationAlg: 'uniform_rand', + seed: 12345, + ); + $gen = new KeyGenerator($config); + + $first = []; + for ($i = 0; $i < 10; $i++) { + $first[] = $gen->nextKey(); + } + $gen->reset(); + $second = []; + for ($i = 0; $i < 10; $i++) { + $second[] = $gen->nextKey(); + } + + self::assertSame($first, $second); + } + + public function testKeyFormatZeroPadsToKeySize(): void + { + $config = new KeyspaceConfig( + keysCount: 100, + keySizeBytes: 16, + keyPrefix: 'bench:', + ); + $gen = new KeyGenerator($config); + + $key = $gen->nextKey(); + + // prefix "bench:" (6) + padding width max(16-6,1)=10 -> "bench:0000000000" + self::assertSame('bench:0000000000', $key); + self::assertSame(16, strlen($key)); + } + + public function testUniformRandMatchesJavaRandomSequence(): void + { + // With uniform_rand + seed, keys are prefix + JavaRandom.nextInt(keysCount). + $config = new KeyspaceConfig( + keysCount: 1000, + keySizeBytes: 16, + keyPrefix: 'bench:', + generationAlg: 'uniform_rand', + seed: 0, + ); + $gen = new KeyGenerator($config); + + $first = $gen->nextKey(); + // JavaRandom(0).nextInt(1000) == 360 (verified against Java canonical LCG). + self::assertSame('bench:0000000360', $first); + } +} diff --git a/php/tests/Unit/RateLimiterTest.php b/php/tests/Unit/RateLimiterTest.php new file mode 100644 index 0000000..4668608 --- /dev/null +++ b/php/tests/Unit/RateLimiterTest.php @@ -0,0 +1,52 @@ +ratePerSecond); + } + + public function testEnforcesApproximateRate(): void + { + // 200 ops/sec -> 30 ops should take ~145ms (29 intervals of 5ms). + $limiter = RateLimiter::create(200); + self::assertNotNull($limiter); + + $start = hrtime(true); + for ($i = 0; $i < 30; $i++) { + $limiter->acquire(); + } + $elapsedMs = (hrtime(true) - $start) / 1_000_000; + + // Expected ~145ms; allow generous tolerance for CI timing jitter. + self::assertGreaterThan(100, $elapsedMs, "Rate limiter too fast: {$elapsedMs}ms"); + self::assertLessThan(400, $elapsedMs, "Rate limiter too slow: {$elapsedMs}ms"); + } + + public function testTryAcquireRespectsInterval(): void + { + $limiter = RateLimiter::create(1); // 1 op/sec + self::assertNotNull($limiter); + + // First is immediately allowed. + self::assertTrue($limiter->tryAcquire()); + // Second should be denied (1s not elapsed). + self::assertFalse($limiter->tryAcquire()); + } +} diff --git a/php/tests/fixtures/recording-driver.json b/php/tests/fixtures/recording-driver.json new file mode 100644 index 0000000..95daa3a --- /dev/null +++ b/php/tests/fixtures/recording-driver.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "Recording client - server-free (PHP engine tests)", + "driver_id": "recording", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/php/tests/fixtures/smoke-workload.json b/php/tests/fixtures/smoke-workload.json new file mode 100644 index 0000000..21e85c6 --- /dev/null +++ b/php/tests/fixtures/smoke-workload.json @@ -0,0 +1,45 @@ +{ + "schema_version": "1.0", + "benchmark_profile": { + "name": "PHP Smoke Test", + "description": "Short two-phase workload for server-free e2e testing" + }, + "phases": [ + { + "id": "WARMUP", + "connections": 4, + "completion": { + "type": "requests", + "requests": 400 + }, + "keyspace": { + "keys_count": 1000, + "key_size_bytes": 16, + "key_prefix": "bench:", + "generation_alg": "sequential_int" + }, + "commands": [ + {"command": "set", "weight": 1.0, "data_size_bytes": 64} + ] + }, + { + "id": "STEADY", + "connections": 4, + "completion": { + "type": "requests", + "requests": 1000 + }, + "keyspace": { + "keys_count": 1000, + "key_size_bytes": 16, + "key_prefix": "bench:", + "generation_alg": "uniform_rand", + "seed": 12345 + }, + "commands": [ + {"command": "get", "weight": 0.8}, + {"command": "set", "weight": 0.2, "data_size_bytes": 64} + ] + } + ] +} diff --git a/scripts/generate_graphs.py b/scripts/generate_graphs.py index 177a7e3..46ff6c8 100644 --- a/scripts/generate_graphs.py +++ b/scripts/generate_graphs.py @@ -71,6 +71,8 @@ # C# drivers "stackexchange-redis": "csharp", "valkey-glide-csharp": "csharp", + # PHP drivers + "valkey-glide-php": "php", # Python drivers (future) "redis-py": "python", "aioredis": "python", diff --git a/scripts/run_benchmark_matrix.py b/scripts/run_benchmark_matrix.py index 4bfcf9d..7e79ad8 100644 --- a/scripts/run_benchmark_matrix.py +++ b/scripts/run_benchmark_matrix.py @@ -124,6 +124,8 @@ # C# drivers "stackexchange-redis": "csharp", "valkey-glide-csharp": "csharp", + # PHP drivers + "valkey-glide-php": "php", # Recording (default to java) "recording": "java", } From 023b0d283c5562cf7449e3c31fedbf45953942c2 Mon Sep 17 00:00:00 2001 From: Prateek Kumar Date: Tue, 15 Sep 2026 10:29:21 -0700 Subject: [PATCH 2/3] Update tests Signed-off-by: Kumar --- Makefile | 15 +- php/README.md | 5 +- php/src/Client/Impl/RecordingClient.php | 41 ++++- php/tests/Integration/EngineTestTrait.php | 61 +++++++ php/tests/Integration/ErrorMetricsTest.php | 104 ++++++++++++ php/tests/Integration/LiveClientTest.php | 177 ++++++++++++++++++++ php/tests/Integration/MetricsOutputTest.php | 155 +++++++++++++++++ php/tests/Integration/RateLimitingTest.php | 151 +++++++++++++++++ php/tools/hdr-crosscheck/HdrCrossCheck.java | 56 +++++++ php/tools/hdr-crosscheck/README.md | 41 +++++ php/tools/hdr-crosscheck/emit.php | 55 ++++++ 11 files changed, 857 insertions(+), 4 deletions(-) create mode 100644 php/tests/Integration/EngineTestTrait.php create mode 100644 php/tests/Integration/ErrorMetricsTest.php create mode 100644 php/tests/Integration/LiveClientTest.php create mode 100644 php/tests/Integration/MetricsOutputTest.php create mode 100644 php/tests/Integration/RateLimitingTest.php create mode 100644 php/tools/hdr-crosscheck/HdrCrossCheck.java create mode 100644 php/tools/hdr-crosscheck/README.md create mode 100644 php/tools/hdr-crosscheck/emit.php diff --git a/Makefile b/Makefile index a6f7bba..300b76f 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ WORK_DIR=$(shell pwd)/work python-build python-test python-run python-clean \ ruby-build ruby-test ruby-run ruby-clean ruby-info \ csharp-build csharp-test csharp-run csharp-clean csharp-info \ - php-build php-test php-integration-test php-run php-clean php-info \ + php-build php-test php-integration-test php-test-live php-run php-clean php-info \ config-editor-build config-editor-dev # ============================================================================ @@ -423,6 +423,19 @@ php-integration-test: exit 1; \ fi +# Live integration tests for the valkey-glide-php driver. Requires the +# valkey_glide extension AND a reachable server (VALKEY_HOST/VALKEY_PORT, +# default localhost:6379). Tests skip cleanly if either is missing. +php-test-live: + cd php && if [ -f vendor/bin/phpunit ]; then \ + vendor/bin/phpunit --testsuite integration --filter LiveClientTest; \ + elif [ -f /tmp/phpunit.phar ]; then \ + $(PHP) /tmp/phpunit.phar --testsuite integration --filter LiveClientTest; \ + else \ + echo "PHPUnit not installed. Run 'make php-build' (composer) or download phpunit.phar."; \ + exit 1; \ + fi + php-run: php-build $(PHP) php/bin/resp-bench \ --server $(SERVER) \ diff --git a/php/README.md b/php/README.md index d93c08d..d01cd15 100644 --- a/php/README.md +++ b/php/README.md @@ -183,8 +183,11 @@ vendor/bin/phpunit --testsuite integration | `ConfigLoaderTest` | `tests/Unit/ConfigLoaderTest.php` | Driver/workload parsing, defaults, cluster mode | | `RateLimiterTest` | `tests/Unit/RateLimiterTest.php` | Leaky-bucket rate enforcement | | — | `tests/Unit/CommandSelectorTest.php` | Weighted command distribution | -| `MetricsOutputTest` | `tests/Unit/HdrEncoderTest.php` | HDR percentiles + V2 compressed encoding structure | +| `MetricsOutputTest` | `tests/Unit/HdrEncoderTest.php`, `tests/Integration/MetricsOutputTest.php` | HDR percentiles + V2 compressed encoding; NDJSON schema, exact request counts, latency accuracy | +| `RateLimitingTest` | `tests/Integration/RateLimitingTest.php` | RPS enforcement, shared limit across (concurrent) connections, unlimited throughput | +| `ErrorMetricsIntegrationTest` | `tests/Integration/ErrorMetricsTest.php` | Error-rate simulation, per-command error counts, errors excluded from latency histogram | | `RecordingBenchmarkClientTest` | `tests/Integration/RecordingWorkloadTest.php` | Full pipeline → NDJSON schema, inline + process modes | +| `BenchmarkIntegrationTest` | `tests/Integration/LiveClientTest.php` | Live server: connect/ping/set-get, driver version, multi-process fork-then-connect (gated; skips without extension/server) | ## Adding a new driver diff --git a/php/src/Client/Impl/RecordingClient.php b/php/src/Client/Impl/RecordingClient.php index 6459111..264fe81 100644 --- a/php/src/Client/Impl/RecordingClient.php +++ b/php/src/Client/Impl/RecordingClient.php @@ -25,9 +25,34 @@ final class RecordingClient extends BenchmarkClient private bool $connected = false; + private float $errorRate = 0.0; + private string $errorMessage = 'Simulated error'; + public function connect(string $host, int $port, DriverConfig $config): void { $this->connected = true; + + // Optional error simulation (parity with the Ruby recording client): + // "specific_driver_config": { "error_rate": 0.1, "error_message": "..." } + $cfg = $config->specificDriverConfig; + if (isset($cfg['error_rate'])) { + $this->errorRate = max(0.0, min(1.0, (float) $cfg['error_rate'])); + } + if (isset($cfg['error_message'])) { + $this->errorMessage = (string) $cfg['error_message']; + } + } + + private function shouldFail(): bool + { + if ($this->errorRate <= 0.0) { + return false; + } + if ($this->errorRate >= 1.0) { + return true; + } + + return (mt_rand() / mt_getrandmax()) < $this->errorRate; } public function isConnected(): bool @@ -39,6 +64,9 @@ public function ping(): TimedResult { return $this->measure(function (): string { $this->recorded[] = ['op' => 'PING', 'key' => '', 'size' => 0]; + if ($this->shouldFail()) { + throw new \RuntimeException($this->errorMessage); + } return 'PONG'; }); @@ -48,6 +76,9 @@ public function get(string $key): TimedResult { return $this->measure(function () use ($key): ?string { $this->recorded[] = ['op' => 'GET', 'key' => $key, 'size' => 0]; + if ($this->shouldFail()) { + throw new \RuntimeException($this->errorMessage); + } return $this->store[$key] ?? null; }); @@ -56,8 +87,11 @@ public function get(string $key): TimedResult public function set(string $key, string $value): TimedResult { return $this->measure(function () use ($key, $value): string { - $this->store[$key] = $value; $this->recorded[] = ['op' => 'SET', 'key' => $key, 'size' => strlen($value)]; + if ($this->shouldFail()) { + throw new \RuntimeException($this->errorMessage); + } + $this->store[$key] = $value; return 'OK'; }); @@ -66,9 +100,12 @@ public function set(string $key, string $value): TimedResult public function del(string $key): TimedResult { return $this->measure(function () use ($key): int { + $this->recorded[] = ['op' => 'DEL', 'key' => $key, 'size' => 0]; + if ($this->shouldFail()) { + throw new \RuntimeException($this->errorMessage); + } $existed = isset($this->store[$key]); unset($this->store[$key]); - $this->recorded[] = ['op' => 'DEL', 'key' => $key, 'size' => 0]; return $existed ? 1 : 0; }); diff --git a/php/tests/Integration/EngineTestTrait.php b/php/tests/Integration/EngineTestTrait.php new file mode 100644 index 0000000..cdaeed1 --- /dev/null +++ b/php/tests/Integration/EngineTestTrait.php @@ -0,0 +1,61 @@ +> + */ + private function runEngine(WorkloadConfig $workload, ?string &$metricsPath = null): array + { + $metricsPath = sys_get_temp_dir() . '/resp_bench_php_it_' . uniqid('', true) . '.ndjson'; + + $engine = new Benchmark( + host: 'localhost', + port: 6379, + driverConfig: $this->recordingDriver(), + workloadConfig: $workload, + metricsPath: $metricsPath, + commitId: 'it-test', + concurrencyMode: 'inline', + ); + $engine->run(); + + $lines = array_values(array_filter(explode("\n", (string) file_get_contents($metricsPath)))); + unlink($metricsPath); + + return array_map( + static fn (string $l): array => json_decode($l, true, 512, JSON_THROW_ON_ERROR), + $lines, + ); + } +} diff --git a/php/tests/Integration/ErrorMetricsTest.php b/php/tests/Integration/ErrorMetricsTest.php new file mode 100644 index 0000000..4404d52 --- /dev/null +++ b/php/tests/Integration/ErrorMetricsTest.php @@ -0,0 +1,104 @@ +runWithErrorRate(1.0, 200); + + self::assertSame(200, $phase['totals']['requests']); + self::assertSame(200, $phase['totals']['errors']); + // Per-command error count matches. + self::assertSame(200, $phase['metrics']['SET']['requests']); + self::assertSame(200, $phase['metrics']['SET']['errors']); + } + + public function testZeroErrorRateProducesNoErrors(): void + { + $phase = $this->runWithErrorRate(0.0, 200); + + self::assertSame(200, $phase['totals']['requests']); + self::assertSame(0, $phase['totals']['errors']); + self::assertSame(0, $phase['metrics']['SET']['errors']); + } + + public function testPartialErrorRateIsApproximatelyHonored(): void + { + $target = 0.25; + $requests = 4000; + $phase = $this->runWithErrorRate($target, $requests); + + self::assertSame($requests, $phase['totals']['requests']); + + $errorFraction = $phase['totals']['errors'] / $requests; + // Statistical: expect ~25%, allow +/- 5 points. + self::assertEqualsWithDelta($target, $errorFraction, 0.05); + } + + public function testErrorsExcludedFromLatencyHistogramCount(): void + { + // With a 100% error rate, no successful latencies are recorded, so the + // per-command latency count is 0 while requests/errors are full. + $phase = $this->runWithErrorRate(1.0, 100); + + self::assertSame(100, $phase['metrics']['SET']['requests']); + self::assertSame(100, $phase['metrics']['SET']['errors']); + self::assertSame(0, $phase['metrics']['SET']['latency']['count']); + } + + /** + * @return array the single phase object + */ + private function runWithErrorRate(float $errorRate, int $requests): array + { + $driver = new DriverConfig( + driverId: 'recording', + mode: 'standalone', + specificDriverConfig: ['error_rate' => $errorRate, 'error_message' => 'Simulated failure'], + ); + + $workload = \RespBench\Config\Loader::parseWorkloadConfigString(<<run(); + + $line = trim((string) file_get_contents($metricsPath)); + unlink($metricsPath); + + return json_decode($line, true, 512, JSON_THROW_ON_ERROR); + } +} diff --git a/php/tests/Integration/LiveClientTest.php b/php/tests/Integration/LiveClientTest.php new file mode 100644 index 0000000..f11e367 --- /dev/null +++ b/php/tests/Integration/LiveClientTest.php @@ -0,0 +1,177 @@ +host = getenv('VALKEY_HOST') ?: 'localhost'; + $this->port = (int) (getenv('VALKEY_PORT') ?: '6379'); + + if (!extension_loaded('valkey_glide')) { + self::markTestSkipped('valkey_glide extension not loaded'); + } + if (!$this->serverReachable()) { + self::markTestSkipped("Server not reachable at {$this->host}:{$this->port}"); + } + } + + private function serverReachable(): bool + { + $errno = 0; + $errstr = ''; + $conn = @fsockopen($this->host, $this->port, $errno, $errstr, 1.0); + if ($conn === false) { + return false; + } + fclose($conn); + + return true; + } + + private function connect(): BenchmarkClient + { + $config = new DriverConfig(driverId: 'valkey-glide-php', mode: 'standalone'); + + return Factory::createAndConnect($this->host, $this->port, $config); + } + + public function testConnects(): void + { + $client = $this->connect(); + try { + self::assertTrue($client->isConnected()); + } finally { + $client->close(); + } + } + + public function testPing(): void + { + $client = $this->connect(); + try { + $result = $client->ping(); + self::assertTrue($result->isSuccess(), 'PING failed'); + self::assertGreaterThan(0, $result->latencyMicros); + } finally { + $client->close(); + } + } + + public function testSetGetRoundTrip(): void + { + $client = $this->connect(); + try { + $key = 'php-live-test-key'; + $value = 'value-' . time(); + + $set = $client->set($key, $value); + self::assertTrue($set->isSuccess(), 'SET failed'); + + $get = $client->get($key); + self::assertTrue($get->isSuccess(), 'GET failed'); + self::assertSame($value, $get->value); + + $client->del($key); + } finally { + $client->close(); + } + } + + public function testDriverVersionIsNonEmpty(): void + { + $client = $this->connect(); + try { + $version = $client->driverVersion(); + self::assertNotSame('', $version); + self::assertNotSame('unknown', $version); + } finally { + $client->close(); + } + } + + /** + * End-to-end multi-process (fork) run against a live server. This is the key + * fork-then-connect validation: N workers each open their own connection + * AFTER forking. Asserts totals add up and latencies are realistic (> 0). + */ + public function testMultiProcessLiveRun(): void + { + if (!function_exists('pcntl_fork')) { + self::markTestSkipped('pcntl not available'); + } + + $metricsPath = sys_get_temp_dir() . '/resp_bench_php_live_' . uniqid('', true) . '.ndjson'; + + $driver = new DriverConfig(driverId: 'valkey-glide-php', mode: 'standalone'); + $workload = Loader::loadWorkloadConfig(__DIR__ . '/../fixtures/smoke-workload.json'); + + $engine = new Benchmark( + host: $this->host, + port: $this->port, + driverConfig: $driver, + workloadConfig: $workload, + metricsPath: $metricsPath, + commitId: 'live-test', + concurrencyMode: 'process', + ); + + try { + $engine->run(); + + $lines = array_values(array_filter(explode("\n", (string) file_get_contents($metricsPath)))); + self::assertCount(2, $lines, 'Expected two phases'); + + $phases = array_map( + static fn (string $l): array => json_decode($l, true, 512, JSON_THROW_ON_ERROR), + $lines, + ); + + // WARMUP: 400 requests, no errors. + self::assertSame('WARMUP', $phases[0]['phase']['id']); + self::assertSame(400, $phases[0]['totals']['requests']); + self::assertSame(0, $phases[0]['totals']['errors']); + + // STEADY: 1000 requests total, no errors, GET+SET sum to total. + self::assertSame('STEADY', $phases[1]['phase']['id']); + self::assertSame(1000, $phases[1]['totals']['requests']); + self::assertSame(0, $phases[1]['totals']['errors']); + $sum = $phases[1]['metrics']['GET']['requests'] + $phases[1]['metrics']['SET']['requests']; + self::assertSame(1000, $sum); + + // Real server latencies should be > 0 microseconds at some percentile. + $getMax = $phases[1]['metrics']['GET']['latency']['summary']['max']; + self::assertGreaterThan(0, $getMax, 'Expected non-zero GET latency against a live server'); + } finally { + if (is_file($metricsPath)) { + unlink($metricsPath); + } + } + } +} diff --git a/php/tests/Integration/MetricsOutputTest.php b/php/tests/Integration/MetricsOutputTest.php new file mode 100644 index 0000000..109cf0d --- /dev/null +++ b/php/tests/Integration/MetricsOutputTest.php @@ -0,0 +1,155 @@ +parseWorkload($this->twoPhaseWorkload()); + $phases = $this->runEngine($workload); + + self::assertCount(2, $phases); + + foreach ($phases as $p) { + // Metadata block. + self::assertArrayHasKey('metadata', $p); + self::assertSame('it-test', $p['metadata']['commit_id']); + self::assertSame('recording', $p['metadata']['driver_id']); + self::assertArrayHasKey('timestamp', $p['metadata']); + self::assertMatchesRegularExpression( + '/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/', + $p['metadata']['timestamp'], + ); + + // Phase block. + self::assertArrayHasKey('phase', $p); + self::assertContains($p['phase']['id'], ['WARMUP', 'STEADY']); + self::assertSame('COMPLETED', $p['phase']['status']); + self::assertArrayHasKey('duration_ms', $p['phase']); + self::assertArrayHasKey('connections', $p['phase']); + + // Totals block. + self::assertArrayHasKey('totals', $p); + self::assertArrayHasKey('requests', $p['totals']); + self::assertArrayHasKey('errors', $p['totals']); + } + } + + public function testRequestCountsAreExact(): void + { + $workload = $this->parseWorkload($this->twoPhaseWorkload()); + $phases = $this->runEngine($workload); + + // WARMUP: 400 SET requests. + self::assertSame(400, $phases[0]['totals']['requests']); + self::assertSame(400, $phases[0]['metrics']['SET']['requests']); + + // STEADY: 1000 total; GET + SET requests sum to total. + self::assertSame(1000, $phases[1]['totals']['requests']); + $sum = $phases[1]['metrics']['GET']['requests'] + $phases[1]['metrics']['SET']['requests']; + self::assertSame(1000, $sum); + + // Per-command latency count equals its (successful) request count. + self::assertSame( + $phases[1]['metrics']['GET']['requests'], + $phases[1]['metrics']['GET']['latency']['count'], + ); + } + + public function testHdrBlockShapeAndUnit(): void + { + $workload = $this->parseWorkload($this->twoPhaseWorkload()); + $phases = $this->runEngine($workload); + + $latency = $phases[1]['metrics']['GET']['latency']; + self::assertSame('us', $latency['unit']); + + foreach (['min', 'p50', 'p95', 'p99', 'p999', 'max'] as $k) { + self::assertArrayHasKey($k, $latency['summary']); + self::assertIsInt($latency['summary'][$k]); + } + + $hdr = $latency['hdr']; + self::assertSame('hdr', $hdr['format']); + self::assertSame(3, $hdr['sigfig']); + self::assertNotSame('', $hdr['payload_b64']); + + // Payload is a valid base64 V2 compressed HDR blob. + $raw = base64_decode($hdr['payload_b64'], true); + self::assertIsString($raw); + /** @var array{cookie:int} $wrapper */ + $wrapper = unpack('Ncookie', substr($raw, 0, 4)); + self::assertSame(0x1c849314, $wrapper['cookie']); + } + + /** + * Latency-distribution accuracy: with a known latency mix, the HDR summary + * percentiles must land in the right buckets. This exercises the same + * histogram + encoder used by the NDJSON writer. + */ + public function testLatencyPercentileAccuracy(): void + { + $h = new HdrHistogram(1, 600_000_000, 3); + // 95 samples at ~100us, 4 at ~1000us, 1 at ~10000us. + for ($i = 0; $i < 95; $i++) { + $h->record(100); + } + for ($i = 0; $i < 4; $i++) { + $h->record(1000); + } + $h->record(10000); + + self::assertEqualsWithDelta(100, $h->valueAtPercentile(50), 1); + self::assertEqualsWithDelta(100, $h->valueAtPercentile(90), 1); + self::assertEqualsWithDelta(1000, $h->valueAtPercentile(99), 10); + self::assertEqualsWithDelta(10000, $h->valueAtPercentile(100), 50); + + // Round-trips through the encoder without error. + $b64 = HdrEncoder::encodeCompressedBase64($h); + self::assertNotSame('', $b64); + } + + private function twoPhaseWorkload(): string + { + return <<parseWorkload($this->rpsWorkload($targetRps, $targetRequests)); + $phases = $this->runEngine($workload); + $phase = $phases[0]; + + // Exact request count. + self::assertSame($targetRequests, $phase['totals']['requests']); + + $durationMs = $phase['phase']['duration_ms']; + self::assertEqualsWithDelta( + $expectedDurationMs, + $durationMs, + $expectedDurationMs * self::RATE_TOLERANCE, + "Duration {$durationMs}ms should be ~{$expectedDurationMs}ms", + ); + + $actualRate = $phase['totals']['requests'] / ($durationMs / 1000.0); + self::assertEqualsWithDelta($targetRps, $actualRate, $targetRps * self::RATE_TOLERANCE); + } + + public function testSharedRpsLimitAcrossConnections(): void + { + // 50 rps shared across 4 connections. This only behaves as a *shared* + // limit under real concurrency, so run in process (fork) mode where the + // 4 workers execute in parallel. Each worker gets 50/4 rps and 100/4 + // requests -> ~2s wall clock, not 4x that. + if (!function_exists('pcntl_fork')) { + self::markTestSkipped('pcntl not available (shared-rate semantics need concurrent workers)'); + } + + $targetRps = 50; + $connections = 4; + $targetRequests = 100; + $expectedDurationMs = ($targetRequests * 1000) / $targetRps; // ~2000ms + + $workload = $this->parseWorkload($this->rpsWorkload($targetRps, $targetRequests, $connections)); + + $metricsPath = sys_get_temp_dir() . '/resp_bench_php_sharedrps_' . uniqid('', true) . '.ndjson'; + $engine = new \RespBench\Engine\Benchmark( + host: 'localhost', + port: 6379, + driverConfig: $this->recordingDriver(), + workloadConfig: $workload, + metricsPath: $metricsPath, + commitId: 'it-test', + concurrencyMode: 'process', + ); + + $start = hrtime(true); + $engine->run(); + $wallMs = (hrtime(true) - $start) / 1_000_000; + + $line = trim((string) file_get_contents($metricsPath)); + unlink($metricsPath); + $phase = json_decode($line, true, 512, JSON_THROW_ON_ERROR); + + self::assertSame($connections, $phase['phase']['connections']); + self::assertSame($targetRequests, $phase['totals']['requests']); + + // Concurrent workers -> aggregate honors the shared limit: wall clock is + // ~2s, not ~8s. Allow generous tolerance for fork + CI jitter. + self::assertGreaterThan( + $expectedDurationMs * 0.7, + $wallMs, + "Wall clock {$wallMs}ms too fast — shared limit not enforced", + ); + self::assertLessThan( + $expectedDurationMs * 2.5, + $wallMs, + "Wall clock {$wallMs}ms too slow — workers not running concurrently (shared limit)", + ); + } + + public function testNoRateLimitAllowsMaximumThroughput(): void + { + $targetRequests = 5000; + $workload = $this->parseWorkload($this->rpsWorkload(-1, $targetRequests)); + $phases = $this->runEngine($workload); + $phase = $phases[0]; + + self::assertSame($targetRequests, $phase['totals']['requests']); + $durationMs = $phase['phase']['duration_ms']; + self::assertLessThan(1000, $durationMs, "Unlimited run should be fast (<1s), was {$durationMs}ms"); + } + + public function testUnlimitedMuchFasterThanRateLimited(): void + { + $targetRequests = 100; + $rps = 50; + + $limited = $this->runEngine($this->parseWorkload($this->rpsWorkload($rps, $targetRequests))); + $unlimited = $this->runEngine($this->parseWorkload($this->rpsWorkload(-1, $targetRequests))); + + $limitedMs = $limited[0]['phase']['duration_ms']; + $unlimitedMs = $unlimited[0]['phase']['duration_ms']; + + $expectedLimitedMs = ($targetRequests * 1000) / $rps; // ~2000ms + self::assertEqualsWithDelta($expectedLimitedMs, $limitedMs, $expectedLimitedMs * self::RATE_TOLERANCE); + + // Unlimited should be dramatically faster. + self::assertLessThan( + $limitedMs / 5, + $unlimitedMs, + "Unlimited ({$unlimitedMs}ms) should be far faster than rate-limited ({$limitedMs}ms)", + ); + } + + private function rpsWorkload(int $rpsLimit, int $requests, int $connections = 1): string + { + return << /tmp/hdr.txt +// javac -cp HdrHistogram.jar HdrCrossCheck.java +// java -cp .:HdrHistogram.jar HdrCrossCheck /tmp/hdr.txt +// +// Compare the "java:" percentile line against the JSON on line 1 of /tmp/hdr.txt. + +import java.nio.ByteBuffer; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Base64; +import java.util.List; +import org.HdrHistogram.Histogram; + +public class HdrCrossCheck { + public static void main(String[] args) throws Exception { + if (args.length < 1) { + System.err.println("Usage: java HdrCrossCheck "); + System.exit(2); + } + + List lines = Files.readAllLines(Path.of(args[0])); + if (lines.size() < 2) { + System.err.println("Expected two lines (summary JSON, base64 payload)"); + System.exit(2); + } + + String phpSummary = lines.get(0); + String base64Payload = lines.get(1).trim(); + + byte[] compressed = Base64.getDecoder().decode(base64Payload); + Histogram h = Histogram.decodeFromCompressedByteBuffer(ByteBuffer.wrap(compressed), 0); + + System.out.println("php: " + phpSummary); + System.out.printf( + "java: {\"count\":%d,\"min\":%d,\"max\":%d,\"p50\":%d,\"p90\":%d,\"p95\":%d,\"p99\":%d,\"p999\":%d}%n", + h.getTotalCount(), + h.getMinValue(), + h.getMaxValue(), + h.getValueAtPercentile(50.0), + h.getValueAtPercentile(90.0), + h.getValueAtPercentile(95.0), + h.getValueAtPercentile(99.0), + h.getValueAtPercentile(99.9) + ); + System.out.println("If php/java percentiles match (within HdrHistogram equivalence), the encoding is compatible."); + } +} diff --git a/php/tools/hdr-crosscheck/README.md b/php/tools/hdr-crosscheck/README.md new file mode 100644 index 0000000..1c1d802 --- /dev/null +++ b/php/tools/hdr-crosscheck/README.md @@ -0,0 +1,41 @@ +# HDR Cross-Check + +Verifies that the PHP engine's HdrHistogram V2 compressed payload is byte-compatible +with the canonical Java HdrHistogram library — the definitive cross-language latency +parity gate. + +## Why + +The PHP `HdrEncoder` produces the same binary V2 compressed format as Java's +`Histogram.encodeIntoCompressedByteBuffer()`. The unit tests assert the payload's +*structure* (cookies, header fields, IEEE754 conversion ratio), but the strongest +check is decoding a real PHP payload with Java and confirming the percentiles match. + +## Run + +```bash +# 1) Emit a payload + PHP-computed percentiles from a fixed sample set. +php php/tools/hdr-crosscheck/emit.php > /tmp/hdr.txt + +# 2) Decode with Java's HdrHistogram and print its percentiles. +# Get the jar, e.g. from Maven Central: org.hdrhistogram:HdrHistogram +javac -cp HdrHistogram.jar php/tools/hdr-crosscheck/HdrCrossCheck.java -d /tmp +java -cp /tmp:HdrHistogram.jar HdrCrossCheck /tmp/hdr.txt +``` + +## Interpret + +The tool prints two lines: + +``` +php: {"count":1005,"min":1,"max":123519,"p50":...,"p90":...,"p95":...,"p99":...,"p999":...} +java: {"count":1005,"min":1,"max":123519,"p50":...,...} +``` + +- `count`, `min`, `max` must match exactly. +- Percentiles must match within HdrHistogram's value-equivalence range (values in the + same bucket are considered equal). Small differences at the bucket boundary are + expected and acceptable; large differences indicate an encoding mismatch. + +A clean match confirms PHP latency data is directly comparable to the Java, Ruby, +and C# engines. diff --git a/php/tools/hdr-crosscheck/emit.php b/php/tools/hdr-crosscheck/emit.php new file mode 100644 index 0000000..dcacb7a --- /dev/null +++ b/php/tools/hdr-crosscheck/emit.php @@ -0,0 +1,55 @@ + /tmp/hdr_payload.txt + */ + +$autoload = __DIR__ . '/../../vendor/autoload.php'; +if (!is_file($autoload)) { + fwrite(STDERR, "Autoloader not found. Run composer install in php/.\n"); + exit(2); +} +require $autoload; + +use RespBench\Metrics\HdrEncoder; +use RespBench\Metrics\HdrHistogram; + +// Fixed, reproducible sample set (microseconds). +$values = []; +for ($i = 1; $i <= 1000; $i++) { + $values[] = $i; // 1..1000 linear +} +foreach ([2500, 5000, 10000, 50000, 123456] as $tail) { + $values[] = $tail; // long tail +} + +$h = new HdrHistogram(1, 600_000_000, 3); +foreach ($values as $v) { + $h->record($v); +} + +$percentiles = [50.0, 90.0, 95.0, 99.0, 99.9]; +$phpSummary = [ + 'count' => $h->totalCount(), + 'min' => $h->min(), + 'max' => $h->max(), +]; +foreach ($percentiles as $p) { + $phpSummary['p' . str_replace('.', '', (string) $p)] = $h->valueAtPercentile($p); +} + +$payload = HdrEncoder::encodeCompressedBase64($h); + +// Machine-readable output: first line = JSON summary, second line = base64 payload. +echo json_encode($phpSummary, JSON_THROW_ON_ERROR) . "\n"; +echo $payload . "\n"; From 574c14f19dd77c4b062fc3d7dd97ec71756791b8 Mon Sep 17 00:00:00 2001 From: Prateek Kumar Date: Tue, 15 Sep 2026 10:29:21 -0700 Subject: [PATCH 3/3] Update tests Signed-off-by: Kumar --- Makefile | 4 +- README.md | 2 +- configs/drivers/default/phpredis.json | 7 + .../drivers/example-phpredis-standalone.json | 7 + configs/drivers/high-throughput/phpredis.json | 7 + configs/matrices/php-driver-comparison.json | 19 ++ php/README.md | 15 +- php/src/Client/Factory.php | 2 + php/src/Client/Impl/PhpRedisClient.php | 176 ++++++++++++++++++ php/tests/Integration/PhpRedisLiveTest.php | 98 ++++++++++ php/tests/Unit/FactoryTest.php | 45 +++++ scripts/generate_graphs.py | 1 + scripts/run_benchmark_matrix.py | 1 + 13 files changed, 380 insertions(+), 4 deletions(-) create mode 100644 configs/drivers/default/phpredis.json create mode 100644 configs/drivers/example-phpredis-standalone.json create mode 100644 configs/drivers/high-throughput/phpredis.json create mode 100644 configs/matrices/php-driver-comparison.json create mode 100644 php/src/Client/Impl/PhpRedisClient.php create mode 100644 php/tests/Integration/PhpRedisLiveTest.php create mode 100644 php/tests/Unit/FactoryTest.php diff --git a/Makefile b/Makefile index 300b76f..175228b 100644 --- a/Makefile +++ b/Makefile @@ -428,9 +428,9 @@ php-integration-test: # default localhost:6379). Tests skip cleanly if either is missing. php-test-live: cd php && if [ -f vendor/bin/phpunit ]; then \ - vendor/bin/phpunit --testsuite integration --filter LiveClientTest; \ + vendor/bin/phpunit --testsuite integration --filter 'LiveClientTest|PhpRedisLiveTest'; \ elif [ -f /tmp/phpunit.phar ]; then \ - $(PHP) /tmp/phpunit.phar --testsuite integration --filter LiveClientTest; \ + $(PHP) /tmp/phpunit.phar --testsuite integration --filter 'LiveClientTest|PhpRedisLiveTest'; \ else \ echo "PHPUnit not installed. Run 'make php-build' (composer) or download phpunit.phar."; \ exit 1; \ diff --git a/README.md b/README.md index 5022f41..71c19ce 100644 --- a/README.md +++ b/README.md @@ -103,7 +103,7 @@ Thread-based system metrics collector that runs alongside benchmarks, collecting | Java | ✅ Ready | Jedis, Lettuce, Valkey-Glide, Redisson, Spring Data Valkey/Redis | | Ruby | ✅ Ready | redis-rb, valkey-glide-ruby | | C# | ✅ Ready | valkey-glide-csharp, StackExchange.Redis | -| PHP | ✅ Ready | valkey-glide-php | +| PHP | ✅ Ready | valkey-glide-php, PHPRedis | | Python | 🚧 Planned | redis-py, aioredis, valkey-glide | ## Project Structure diff --git a/configs/drivers/default/phpredis.json b/configs/drivers/default/phpredis.json new file mode 100644 index 0000000..8ce61cc --- /dev/null +++ b/configs/drivers/default/phpredis.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "PHPRedis (ext-redis) - default configuration", + "driver_id": "phpredis", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/configs/drivers/example-phpredis-standalone.json b/configs/drivers/example-phpredis-standalone.json new file mode 100644 index 0000000..225300c --- /dev/null +++ b/configs/drivers/example-phpredis-standalone.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "PHPRedis (ext-redis) client - standalone mode", + "driver_id": "phpredis", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/configs/drivers/high-throughput/phpredis.json b/configs/drivers/high-throughput/phpredis.json new file mode 100644 index 0000000..bf52065 --- /dev/null +++ b/configs/drivers/high-throughput/phpredis.json @@ -0,0 +1,7 @@ +{ + "schema_version": "1.0", + "description": "PHPRedis (ext-redis) - high-throughput configuration", + "driver_id": "phpredis", + "mode": "standalone", + "specific_driver_config": {} +} diff --git a/configs/matrices/php-driver-comparison.json b/configs/matrices/php-driver-comparison.json new file mode 100644 index 0000000..bcccd08 --- /dev/null +++ b/configs/matrices/php-driver-comparison.json @@ -0,0 +1,19 @@ +{ + "description": "Compare PHP clients (Valkey GLIDE PHP vs PHPRedis) across client counts", + "x_axis": "connections", + "workload_template": "configs/workloads/reference/basic-standalone-single-client-1M-reqs.json", + "iterations": 5, + "dimensions": { + "connections": [ + 1, + 2, + 4, + 8, + 16 + ], + "driver_config": [ + "configs/drivers/high-throughput/valkey-glide-php.json", + "configs/drivers/high-throughput/phpredis.json" + ] + } +} diff --git a/php/README.md b/php/README.md index d01cd15..bc84f48 100644 --- a/php/README.md +++ b/php/README.md @@ -7,6 +7,7 @@ PHP implementation of the resp-bench benchmark suite for Redis/Valkey compatible | Driver ID | Package | Description | |-----------|---------|-------------| | `valkey-glide-php` | [ext-valkey_glide](https://github.com/valkey-io/valkey-glide-php) | Valkey GLIDE PHP client — Rust-core client exposed as a native PHP extension, with a PHPRedis-compatible API | +| `phpredis` | [ext-redis](https://github.com/phpredis/phpredis) | PHPRedis — the de-facto standard PHP Redis/Valkey client (the incumbent comparison baseline) | | `recording` | (built-in) | In-memory driver for server-free tests and pipeline validation | ## Prerequisites @@ -15,7 +16,8 @@ PHP implementation of the resp-bench benchmark suite for Redis/Valkey compatible - `ext-pcntl` (for the multi-process concurrency model; standard on Linux/macOS CLI builds) - `ext-json` (bundled with PHP) - Composer (recommended) — or use the bundled minimal autoloader -- For live-server runs: the `valkey_glide` extension installed and enabled +- For live-server runs: the `valkey_glide` extension (for `valkey-glide-php`) and/or + the `redis` extension (for `phpredis`) installed and enabled ### Installing the Valkey GLIDE PHP extension @@ -37,6 +39,17 @@ php -m | grep valkey_glide The `recording` driver needs neither the extension nor a server, so unit and integration tests run without either. +### Installing PHPRedis + +The `phpredis` driver requires the `redis` extension (ext-redis): + +```bash +pecl install redis +# then enable in php.ini: extension=redis +php -m | grep redis +``` + + ## Installation ```bash diff --git a/php/src/Client/Factory.php b/php/src/Client/Factory.php index 73ca0ca..7bc14cd 100644 --- a/php/src/Client/Factory.php +++ b/php/src/Client/Factory.php @@ -5,6 +5,7 @@ namespace RespBench\Client; use InvalidArgumentException; +use RespBench\Client\Impl\PhpRedisClient; use RespBench\Client\Impl\RecordingClient; use RespBench\Client\Impl\ValkeyGlidePhpClient; use RespBench\Config\DriverConfig; @@ -17,6 +18,7 @@ final class Factory /** @var array> */ private const DRIVERS = [ 'valkey-glide-php' => ValkeyGlidePhpClient::class, + 'phpredis' => PhpRedisClient::class, 'recording' => RecordingClient::class, ]; diff --git a/php/src/Client/Impl/PhpRedisClient.php b/php/src/Client/Impl/PhpRedisClient.php new file mode 100644 index 0000000..3ffa96e --- /dev/null +++ b/php/src/Client/Impl/PhpRedisClient.php @@ -0,0 +1,176 @@ +connect($host, $port); + * $r->set('k','v'); $r->get('k'); $r->ping(); $r->del('k'); $r->close(); + * + * As with the GLIDE client, each forked worker constructs and connects its own + * instance AFTER forking — connections are never inherited across a fork. + */ +final class PhpRedisClient extends BenchmarkClient +{ + private ?object $client = null; + + public function connect(string $host, int $port, DriverConfig $config): void + { + if (!extension_loaded('redis')) { + throw new RuntimeException( + 'The PHPRedis extension (ext-redis) is not loaded. ' + . 'Install it (pecl install redis) and add extension=redis to php.ini.' + ); + } + + if ($config->isCluster()) { + $this->client = $this->makeCluster($host, $port, $config); + + return; + } + + /** @var object $redis */ + $redis = new \Redis(); + + // Optional TLS: PHPRedis uses a tls:// host scheme. + $connectHost = $host; + $sslContext = null; + if ($config->tls !== null) { + $connectHost = 'tls://' . $host; + $sslContext = $this->buildSslContext($config->tls); + } + + // connect(host, port, timeout, persistent_id, retry_interval, read_timeout, context) + $timeoutSeconds = 0.5; + $ok = $sslContext !== null + ? $redis->connect($connectHost, $port, $timeoutSeconds, null, 0, 0, ['stream' => $sslContext]) + : $redis->connect($connectHost, $port, $timeoutSeconds); + + if ($ok === false) { + throw new RuntimeException("PHPRedis failed to connect to {$host}:{$port}"); + } + + if ($config->auth !== null && isset($config->auth['password'])) { + $auth = isset($config->auth['username']) + ? [(string) $config->auth['username'], (string) $config->auth['password']] + : (string) $config->auth['password']; + $redis->auth($auth); + } + + $this->client = $redis; + } + + private function makeCluster(string $host, int $port, DriverConfig $config): object + { + if (!class_exists('RedisCluster')) { + throw new RuntimeException('RedisCluster not available from ext-redis.'); + } + + // RedisCluster(name, seeds, timeout, read_timeout, persistent, auth) + $seeds = ["{$host}:{$port}"]; + $auth = null; + if ($config->auth !== null && isset($config->auth['password'])) { + $auth = (string) $config->auth['password']; + } + + /** @psalm-suppress MixedMethodCall */ + return new \RedisCluster(null, $seeds, 0.5, 0.5, false, $auth); + } + + /** + * @param array $tls + * @return array + */ + private function buildSslContext(array $tls): array + { + $ssl = []; + if (isset($tls['ca_cert_path'])) { + $ssl['cafile'] = (string) $tls['ca_cert_path']; + } + if (isset($tls['cert_path'])) { + $ssl['local_cert'] = (string) $tls['cert_path']; + } + if (isset($tls['key_path'])) { + $ssl['local_pk'] = (string) $tls['key_path']; + } + if (isset($tls['verify_peer'])) { + $ssl['verify_peer'] = (bool) $tls['verify_peer']; + } + + return $ssl; + } + + public function isConnected(): bool + { + if ($this->client === null) { + return false; + } + + try { + return $this->client->ping() !== false; + } catch (\Throwable) { + return false; + } + } + + public function ping(): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->ping()); + } + + public function get(string $key): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->get($key)); + } + + public function set(string $key, string $value): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->set($key, $value)); + } + + public function del(string $key): TimedResult + { + return $this->measure(fn (): mixed => $this->requireClient()->del($key)); + } + + public function close(): void + { + if ($this->client !== null) { + try { + $this->client->close(); + } catch (\Throwable) { + // ignore + } + $this->client = null; + } + } + + public function driverVersion(): string + { + $version = phpversion('redis'); + + return $version !== false ? $version : 'unknown'; + } + + private function requireClient(): object + { + if ($this->client === null) { + throw new RuntimeException('PHPRedis client is not connected.'); + } + + return $this->client; + } +} diff --git a/php/tests/Integration/PhpRedisLiveTest.php b/php/tests/Integration/PhpRedisLiveTest.php new file mode 100644 index 0000000..4a1b97c --- /dev/null +++ b/php/tests/Integration/PhpRedisLiveTest.php @@ -0,0 +1,98 @@ +host = getenv('VALKEY_HOST') ?: 'localhost'; + $this->port = (int) (getenv('VALKEY_PORT') ?: '6379'); + + if (!extension_loaded('redis')) { + self::markTestSkipped('redis (phpredis) extension not loaded'); + } + if (!$this->serverReachable()) { + self::markTestSkipped("Server not reachable at {$this->host}:{$this->port}"); + } + } + + private function serverReachable(): bool + { + $conn = @fsockopen($this->host, $this->port, $errno, $errstr, 1.0); + if ($conn === false) { + return false; + } + fclose($conn); + + return true; + } + + private function connect(): BenchmarkClient + { + return Factory::createAndConnect( + $this->host, + $this->port, + new DriverConfig(driverId: 'phpredis', mode: 'standalone'), + ); + } + + public function testConnectsAndPings(): void + { + $client = $this->connect(); + try { + self::assertTrue($client->isConnected()); + $ping = $client->ping(); + self::assertTrue($ping->isSuccess()); + } finally { + $client->close(); + } + } + + public function testSetGetRoundTrip(): void + { + $client = $this->connect(); + try { + $key = 'phpredis-live-test-key'; + $value = 'value-' . time(); + + self::assertTrue($client->set($key, $value)->isSuccess()); + + $get = $client->get($key); + self::assertTrue($get->isSuccess()); + self::assertSame($value, $get->value); + + $client->del($key); + } finally { + $client->close(); + } + } + + public function testDriverVersionIsNonEmpty(): void + { + $client = $this->connect(); + try { + $version = $client->driverVersion(); + self::assertNotSame('', $version); + self::assertNotSame('unknown', $version); + } finally { + $client->close(); + } + } +} diff --git a/php/tests/Unit/FactoryTest.php b/php/tests/Unit/FactoryTest.php new file mode 100644 index 0000000..a1fcfba --- /dev/null +++ b/php/tests/Unit/FactoryTest.php @@ -0,0 +1,45 @@ +expectException(InvalidArgumentException::class); + Factory::create('nonexistent-driver'); + } +} diff --git a/scripts/generate_graphs.py b/scripts/generate_graphs.py index 46ff6c8..ad05cba 100644 --- a/scripts/generate_graphs.py +++ b/scripts/generate_graphs.py @@ -73,6 +73,7 @@ "valkey-glide-csharp": "csharp", # PHP drivers "valkey-glide-php": "php", + "phpredis": "php", # Python drivers (future) "redis-py": "python", "aioredis": "python", diff --git a/scripts/run_benchmark_matrix.py b/scripts/run_benchmark_matrix.py index 7e79ad8..00365f9 100644 --- a/scripts/run_benchmark_matrix.py +++ b/scripts/run_benchmark_matrix.py @@ -126,6 +126,7 @@ "valkey-glide-csharp": "csharp", # PHP drivers "valkey-glide-php": "php", + "phpredis": "php", # Recording (default to java) "recording": "java", }