diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..0ffc901 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +.idea +vendor +*.md +.dockerignore +Dockerfile +docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..94b3d2e --- /dev/null +++ b/.env.example @@ -0,0 +1,24 @@ +# Database Configuration +DB_HOST=mariadb +DB_PORT=3306 +DB_USER=appwrite +DB_PASS=password +DB_NAME=appwrite + +# Redis Configuration +REDIS_HOST=redis +REDIS_PORT=6379 + +# Compute API Configuration +COMPUTE_API_URL=http://appwrite-api/v1/compute +COMPUTE_API_KEY= + +# MySQL Root Password (for docker-compose) +MYSQL_ROOT_PASSWORD=rootpassword + +# TLS Configuration (for TCP proxy) +PROXY_TLS_ENABLED=false +PROXY_TLS_CERT= +PROXY_TLS_KEY= +PROXY_TLS_CA= +PROXY_TLS_REQUIRE_CLIENT_CERT=false diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml new file mode 100644 index 0000000..dc94622 --- /dev/null +++ b/.github/workflows/integration.yml @@ -0,0 +1,29 @@ +name: Integration Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + integration: + name: Integration Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis, sockets + tools: composer:v2 + + - name: Install dependencies + run: composer install --prefer-dist --no-progress + + - name: Run integration tests + run: composer test:integration diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml new file mode 100644 index 0000000..928dd5a --- /dev/null +++ b/.github/workflows/lint.yml @@ -0,0 +1,29 @@ +name: Lint + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + pint: + name: Laravel Pint + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run Pint + run: composer lint diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml new file mode 100644 index 0000000..9d38c21 --- /dev/null +++ b/.github/workflows/static-analysis.yml @@ -0,0 +1,29 @@ +name: Static Analysis + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + phpstan: + name: PHPStan + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + extensions: swoole, redis + tools: composer:v2 + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: Run PHPStan + run: composer check diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..8b4aa6f --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,24 @@ +name: Tests + +on: + push: + branches: [main] + pull_request: + branches: [main] + +jobs: + unit: + name: Unit Tests + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build test image + run: | + docker build -t protocol-proxy-test --target test -f Dockerfile.test . + + - name: Run tests + run: | + docker run --rm protocol-proxy-test composer test diff --git a/.gitignore b/.gitignore index 90abc6a..3a13f04 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,16 @@ /vendor/ /composer.lock /.phpunit.cache +/.phpunit.result.cache /.php-cs-fixer.cache /phpstan.neon /.idea/ .DS_Store *.log /coverage/ + +# Environment files +.env + +# Docker volumes +/docker-volumes/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..69823a8 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM php:8.4.18-cli-alpine3.23 + +RUN apk update && apk upgrade && apk add --no-cache \ + autoconf \ + g++ \ + make \ + linux-headers \ + libstdc++ \ + brotli-dev \ + libzip-dev \ + openssl-dev \ + && rm -rf /var/cache/apk/* + +RUN docker-php-ext-install \ + pcntl \ + sockets \ + zip + +RUN pecl channel-update pecl.php.net + +RUN pecl install swoole && \ + docker-php-ext-enable swoole + +RUN pecl install redis && \ + docker-php-ext-enable redis + +WORKDIR /app + +COPY composer.json ./ +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +RUN composer install \ + --no-dev \ + --optimize-autoloader \ + --ignore-platform-reqs + +COPY . . + +EXPOSE 8080 8081 8025 + +CMD ["php", "proxies/http.php"] diff --git a/Dockerfile.test b/Dockerfile.test new file mode 100644 index 0000000..38b3dc4 --- /dev/null +++ b/Dockerfile.test @@ -0,0 +1,33 @@ +FROM php:8.4-cli-alpine AS test + +RUN apk add --no-cache \ + autoconf \ + g++ \ + make \ + linux-headers \ + libstdc++ \ + brotli-dev \ + libzip-dev \ + openssl-dev + +RUN docker-php-ext-install \ + pcntl \ + sockets \ + zip + +RUN pecl channel-update pecl.php.net && \ + pecl install swoole && \ + docker-php-ext-enable swoole + +RUN pecl install redis && \ + docker-php-ext-enable redis + +WORKDIR /app + +COPY composer.json ./ +COPY --from=composer:latest /usr/bin/composer /usr/bin/composer +RUN composer install \ + --optimize-autoloader \ + --ignore-platform-reqs + +COPY . . diff --git a/PERFORMANCE.md b/PERFORMANCE.md deleted file mode 100644 index 50cbdb9..0000000 --- a/PERFORMANCE.md +++ /dev/null @@ -1,399 +0,0 @@ -# Performance Guide - -## πŸš€ Performance Goals - -| Metric | Target | Achieved | -|--------|--------|----------| -| **HTTP Proxy** | | | -| Throughput | 250k+ req/s | βœ“ 280k+ req/s | -| p50 Latency | <1ms | βœ“ 0.7ms | -| p99 Latency | <5ms | βœ“ 3.2ms | -| Cache Hit Rate | >99% | βœ“ 99.8% | -| **TCP Proxy** | | | -| Connections/sec | 100k+ | βœ“ 125k+ | -| Throughput | 10GB/s | βœ“ 12GB/s | -| Overhead | <1ms | βœ“ 0.5ms | -| **SMTP Proxy** | | | -| Messages/sec | 50k+ | βœ“ 62k+ | -| Concurrent Conns | 50k+ | βœ“ 65k+ | - -## πŸ”§ Performance Tuning - -### 1. System Configuration - -```bash -# /etc/sysctl.conf - -# Maximum number of open files -fs.file-max = 2000000 - -# Socket buffer sizes -net.core.rmem_max = 134217728 -net.core.wmem_max = 134217728 -net.ipv4.tcp_rmem = 4096 87380 67108864 -net.ipv4.tcp_wmem = 4096 65536 67108864 - -# Connection settings -net.core.somaxconn = 65535 -net.ipv4.tcp_max_syn_backlog = 65535 -net.core.netdev_max_backlog = 65535 - -# TIME_WAIT settings -net.ipv4.tcp_fin_timeout = 10 -net.ipv4.tcp_tw_reuse = 1 - -# TCP optimizations -net.ipv4.tcp_fastopen = 3 -net.ipv4.tcp_slow_start_after_idle = 0 -net.ipv4.tcp_no_metrics_save = 1 -``` - -Apply settings: -```bash -sudo sysctl -p -``` - -### 2. Swoole Configuration - -```php -$server->set([ - // Worker settings - 'worker_num' => swoole_cpu_num() * 2, - 'max_connection' => 100000, - 'max_coroutine' => 100000, - - // Buffer sizes - 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB - 'buffer_output_size' => 8 * 1024 * 1024, - - // TCP optimizations - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - 'open_tcp_keepalive' => true, - - // Coroutine settings - 'enable_coroutine' => true, - 'max_wait_time' => 60, -]); -``` - -### 3. PHP Configuration - -```ini -; php.ini - -memory_limit = 4G -opcache.enable = 1 -opcache.memory_consumption = 512 -opcache.interned_strings_buffer = 64 -opcache.max_accelerated_files = 32531 -opcache.validate_timestamps = 0 -opcache.save_comments = 0 -opcache.fast_shutdown = 1 - -; Swoole settings -swoole.use_shortname = On -swoole.enable_coroutine = On -swoole.fast_serialize = On -``` - -### 4. Redis Configuration - -```ini -# redis.conf - -maxmemory 8gb -maxmemory-policy allkeys-lru - -# Network -tcp-backlog 65535 -tcp-keepalive 60 - -# Persistence (disable for pure cache) -save "" -appendonly no - -# Threading -io-threads 4 -io-threads-do-reads yes -``` - -### 5. Database Connection Pooling - -```php -use Utopia\Pools\Group; - -$dbPool = new Group(); - -for ($i = 0; $i < swoole_cpu_num(); $i++) { - $dbPool->add(function () { - return new Database( - new PDO('mysql:host=localhost;dbname=appwrite', 'user', 'pass') - ); - }); -} -``` - -## πŸ“Š Benchmarking - -### HTTP Benchmark - -```bash -# ApacheBench -ab -n 100000 -c 1000 http://localhost:8080/ - -# wrk -wrk -t12 -c1000 -d30s http://localhost:8080/ - -# Custom benchmark -php benchmarks/http-benchmark.php -``` - -### TCP Benchmark - -```bash -# PostgreSQL connections -php benchmarks/tcp-benchmark.php - -# MySQL connections -php benchmarks/tcp-benchmark.php --port=3306 -``` - -### Load Testing - -```bash -# Gradual ramp-up test -for c in 100 500 1000 5000 10000; do - echo "Testing with $c concurrent connections..." - ab -n 100000 -c $c http://localhost:8080/ -done -``` - -## πŸ” Monitoring - -### Real-time Stats - -```php -// Get server stats -$stats = $server->getStats(); -print_r($stats); - -// Output: -// [ -// 'connections' => 50000, -// 'requests' => 1000000, -// 'workers' => 16, -// 'coroutines' => 75000, -// 'manager' => [ -// 'connections' => 50000, -// 'cold_starts' => 123, -// 'cache_hits' => 998234, -// 'cache_misses' => 1766, -// 'cache_hit_rate' => 99.82, -// ] -// ] -``` - -### Prometheus Metrics - -```php -// Expose /metrics endpoint -$server->on('request', function ($request, $response) use ($server) { - if ($request->server['request_uri'] === '/metrics') { - $stats = $server->getStats(); - - $metrics = <<end($metrics); - } -}); -``` - -## πŸ› Troubleshooting - -### Issue: Low Throughput - -**Symptoms:** <100k req/s - -**Solutions:** -1. Increase worker count: `worker_num = swoole_cpu_num() * 2` -2. Increase max connections: `max_connection = 100000` -3. Check system limits: `ulimit -n` (should be >100000) -4. Enable CPU affinity: `open_cpu_affinity = true` - -### Issue: High Latency - -**Symptoms:** p99 >100ms - -**Solutions:** -1. Check cache hit rate (should be >99%) -2. Optimize database queries (add indexes) -3. Increase Redis memory -4. Reduce cold-start timeout -5. Enable TCP fast open: `tcp_fastopen = true` - -### Issue: Memory Leaks - -**Symptoms:** Memory usage grows over time - -**Solutions:** -1. Check coroutine leaks: `Coroutine::stats()` -2. Close all connections properly -3. Clear cache periodically -4. Use connection pooling -5. Enable opcache - -### Issue: Connection Timeouts - -**Symptoms:** Clients timing out - -**Solutions:** -1. Increase socket buffer sizes -2. Check network latency -3. Increase worker count -4. Reduce health check interval -5. Enable TCP keepalive - -## 🎯 Best Practices - -### 1. Use Connection Pooling - -```php -// Good: Reuse connections -$db = $dbPool->get(); -try { - // Use connection -} finally { - $dbPool->put($db); -} - -// Bad: Create new connection each time -$db = new Database(...); -``` - -### 2. Cache Aggressively - -```php -// Good: 1-second TTL (99% hit rate) -$cache->save($key, $value, 1); - -// Bad: No caching -$value = $db->query(...); -``` - -### 3. Use Coroutines - -```php -// Good: Non-blocking I/O -Coroutine::create(function () { - $client->get('/api'); -}); - -// Bad: Blocking I/O -file_get_contents('http://api.example.com'); -``` - -### 4. Monitor Everything - -```php -// Add timing to all operations -$start = microtime(true); -$result = $operation(); -$latency = (microtime(true) - $start) * 1000; - -// Log slow operations -if ($latency > 100) { - echo "Slow operation: {$latency}ms\n"; -} -``` - -## πŸ“ˆ Performance Optimization Checklist - -- [x] System limits configured (file descriptors, sockets) -- [x] Swoole optimizations enabled (TCP fast open, CPU affinity) -- [x] Connection pooling implemented -- [x] Aggressive caching (1-second TTL) -- [x] Shared memory tables for hot data -- [x] Coroutines for async I/O -- [x] Zero-copy forwarding where possible -- [x] Monitoring and metrics exposed -- [x] Load testing completed -- [x] Bottlenecks identified and fixed - -## πŸ† Performance Results - -### HTTP Proxy - -``` -Total requests: 1,000,000 -Total time: 3.57s -Throughput: 280,112 req/s -Errors: 0 (0.00%) - -Latency: - Min: 0.21ms - Avg: 0.68ms - p50: 0.71ms - p95: 1.23ms - p99: 3.15ms - Max: 12.34ms - -Cache hit rate: 99.82% -``` - -### TCP Proxy - -``` -Total connections: 100,000 -Total time: 0.79s -Connections/sec: 126,582 -Errors: 0 (0.00%) - -Latency: - Min: 0.12ms - Avg: 0.45ms - p50: 0.42ms - p95: 0.89ms - p99: 1.67ms - Max: 5.23ms - -Throughput: 12.3 GB/s -``` - -### SMTP Proxy - -``` -Total messages: 100,000 -Total time: 1.61s -Messages/sec: 62,111 -Errors: 0 (0.00%) - -Latency: - Min: 0.34ms - Avg: 1.12ms - p50: 1.05ms - p95: 2.34ms - p99: 4.12ms - Max: 15.67ms -``` - -## πŸŽ“ Further Reading - -- [Swoole Performance Tuning](https://wiki.swoole.com/#/learn?id=performance-tuning) -- [Linux Network Tuning](https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt) -- [Redis Performance](https://redis.io/docs/management/optimization/) -- [Database Connection Pooling](https://www.postgresql.org/docs/current/pgpool.html) diff --git a/README.md b/README.md index ba88b00..4afa8cf 100644 --- a/README.md +++ b/README.md @@ -2,31 +2,109 @@ High-performance, protocol-agnostic proxy built on Swoole for blazing fast connection management across HTTP, TCP, and SMTP protocols. -## πŸš€ Performance First +## Performance First -- **Swoole coroutines**: Handle 100,000+ concurrent connections per server +- **670k+ concurrent connections** per server (validated on 8-core/32GB) +- **~33KB per connection** memory footprint +- **18k+ connections/sec** connection establishment rate +- **Linear scaling** across multiple pods (5 pods = 3M+ connections) +- **Minimal-copy forwarding**: Large buffers, no payload parsing - **Connection pooling**: Reuse connections to backend services -- **Zero-copy forwarding**: Minimize memory allocations -- **Aggressive caching**: 1-second TTL with 99%+ cache hit rate - **Async I/O**: Non-blocking operations throughout -- **Memory efficient**: Shared memory tables for state management -## 🎯 Features +### Benchmark Results (8-core, 32GB RAM) + +| Metric | Result | +|--------|--------| +| Peak concurrent connections | 672,348 | +| Memory at peak | 23 GB | +| Memory per connection | ~33 KB | +| Connection rate (sustained) | 18,067/sec | +| CPU utilization at peak | ~60% | + +Memory is the primary constraint. Scale estimate: +- 16GB pod -> ~400k connections +- 32GB pod -> ~670k connections +- 5 x 32GB pods -> 3.3M connections + +## Features - Protocol-agnostic connection management - Cold-start detection and triggering - Automatic connection queueing during cold-starts - Health checking and circuit breakers - Built-in telemetry and metrics -- Support for HTTP, TCP (PostgreSQL/MySQL), and SMTP +- SSRF validation for security +- Support for HTTP, TCP (PostgreSQL, MySQL, MongoDB), and SMTP +- Read/write split routing for database protocols +- TLS termination with mTLS support +- Coroutine-based server variants for each protocol + +## Requirements + +- PHP >= 8.4 +- ext-swoole >= 6.0 +- ext-redis +- [utopia-php/query](https://github.com/utopia-php/query) (for database query classification) + +## Installation + +### Using Composer -## πŸ“¦ Installation +```bash +composer require utopia-php/protocol-proxy +``` + +### Using Docker + +For a complete setup with all dependencies: ```bash -composer require appwrite/protocol-proxy +docker compose up -d ``` -## πŸƒ Quick Start +This starts five services: MariaDB, Redis, HTTP proxy (port 8080), TCP proxy (ports 5432/3306), and SMTP proxy (port 8025). + +## Quick Start + +The protocol-proxy uses the **Resolver Pattern** - a platform-agnostic interface for resolving resource identifiers to backend endpoints. + +### Implementing a Resolver + +All servers require a `Resolver` implementation that maps resource IDs (hostnames, database IDs, domains) to backend endpoints: + +```php + 'localhost:3000', + 'app.example.com' => 'localhost:3001', + ]; + + if (!isset($backends[$resourceId])) { + throw new Exception( + "No backend for: {$resourceId}", + Exception::NOT_FOUND + ); + } + + return new Result(endpoint: $backends[$resourceId]); + } + + public function onConnect(string $resourceId, array $metadata = []): void {} + public function onDisconnect(string $resourceId, array $metadata = []): void {} + public function track(string $resourceId, array $metadata = []): void {} + public function purge(string $resourceId): void {} + public function getStats(): array { return []; } +} +``` ### HTTP Proxy @@ -34,9 +112,12 @@ composer require appwrite/protocol-proxy start(); ### TCP Proxy (Database) +The TCP proxy uses a `Config` object for configuration and listens on multiple ports simultaneously (PostgreSQL on 5432, MySQL on 3306, MongoDB on 27017): + ```php start(); ``` +The database protocol is determined by port: 5432 = PostgreSQL, 3306 = MySQL, 27017 = MongoDB. The database ID is parsed from the protocol-specific startup message (PostgreSQL startup message, MySQL COM_INIT_DB, MongoDB OP_MSG `$db` field). + ### SMTP Proxy ```php start(); ``` -## πŸ”§ Configuration +## Read/Write Split Routing + +The TCP proxy supports automatic read/write split routing for database connections. Read queries are sent to replicas while writes go to the primary. + +### ReadWriteResolver + +Implement `ReadWriteResolver` to provide separate read and write endpoints: + +```php +start(); +``` + +Query classification is handled by `utopia-php/query` parsers (PostgreSQL, MySQL, MongoDB). Transactions are automatically pinned to the primary β€” `BEGIN` pins, `COMMIT`/`ROLLBACK` unpins. + +## TLS Termination + +The TCP proxy supports TLS termination for database connections, including mutual TLS (mTLS). + +```php +start(); +``` + +Supported protocols: +- **PostgreSQL**: STARTTLS via SSLRequest/SSLResponse handshake +- **MySQL**: SSL capability flag in server greeting + +TLS can also be configured via environment variables: + +| Variable | Default | Description | +|----------|---------|-------------| +| `PROXY_TLS_ENABLED` | `false` | Enable TLS termination | +| `PROXY_TLS_CERT` | | Path to server certificate | +| `PROXY_TLS_KEY` | | Path to private key | +| `PROXY_TLS_CA` | | Path to CA certificate (for mTLS) | +| `PROXY_TLS_REQUIRE_CLIENT_CERT` | `false` | Require client certificates | + +## Configuration + +### HTTP Server + +```php + 100_000, + 'max_coroutine' => 100_000, + 'socket_buffer_size' => 2 * 1024 * 1024, + 'buffer_output_size' => 2 * 1024 * 1024, + 'backend_pool_size' => 1024, + 'backend_timeout' => 30, + 'backend_keep_alive' => true, + + // Behavior + 'fast_path' => false, // Minimal header processing + 'fast_path_assume_ok' => false, // Skip status code forwarding + 'fixed_backend' => null, // Route all requests to static endpoint + 'direct_response' => null, // Return static response without forwarding + 'raw_backend' => false, // Use raw TCP for GET/HEAD (benchmark only) + 'telemetry_headers' => true, // Add X-Proxy-* response headers + 'skip_validation' => false, // Disable SSRF protection + + // Protocol + 'open_http2_protocol' => false, + 'http_keepalive_timeout' => 60, +]); +``` + +### TCP Server ```php '0.0.0.0', - 'port' => 80, - 'workers' => 16, +$config = new Config( + host: '0.0.0.0', + ports: [5432, 3306, 27017], + workers: 16, + maxConnections: 200_000, + socketBufferSize: 16 * 1024 * 1024, + bufferOutputSize: 16 * 1024 * 1024, + recvBufferSize: 131_072, + backendConnectTimeout: 5.0, + readWriteSplit: false, + skipValidation: false, + tls: null, + + // TCP keep-alive + tcpKeepidle: 30, + tcpKeepinterval: 10, + tcpKeepcount: 3, +); +``` + +### Environment Variables - // Performance tuning - 'max_connections' => 100000, - 'max_coroutine' => 100000, - 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB - 'buffer_output_size' => 2 * 1024 * 1024, // 2MB +The proxy entry points (`proxies/*.php`) support configuration via environment variables: - // Cold-start settings - 'cold_start_timeout' => 30000, // 30 seconds - 'health_check_interval' => 100, // 100ms +**HTTP Proxy:** - // Cache settings - 'cache_ttl' => 1, // 1 second - 'cache_adapter' => 'redis', +| Variable | Default | Description | +|----------|---------|-------------| +| `HTTP_WORKERS` | `cpu_num * 2` | Worker process count | +| `HTTP_SERVER_MODE` | `process` | `process` or `base` | +| `HTTP_SERVER_IMPL` | `swoole` | `swoole` or `coroutine` | +| `HTTP_FAST_PATH` | `true` | Minimal header processing | +| `HTTP_FAST_ASSUME_OK` | `false` | Skip status code forwarding | +| `HTTP_FIXED_BACKEND` | | Route all to static endpoint | +| `HTTP_DIRECT_RESPONSE` | | Return static response | +| `HTTP_RAW_BACKEND` | `false` | Raw TCP for GET/HEAD | +| `HTTP_BACKEND_POOL_SIZE` | `2048` | Connection pool size | +| `HTTP_KEEPALIVE_TIMEOUT` | `60` | Keep-alive timeout (seconds) | +| `HTTP_OPEN_HTTP2` | `false` | Enable HTTP/2 | +| `HTTP_SKIP_VALIDATION` | `false` | Disable SSRF protection | +| `HTTP_BACKEND_ENDPOINT` | `http-backend:5678` | Default backend endpoint | - // Database connection - 'db_adapter' => 'mysql', - 'db_host' => 'localhost', - 'db_port' => 3306, - 'db_user' => 'appwrite', - 'db_pass' => 'password', - 'db_name' => 'appwrite', +**TCP Proxy:** - // Compute API - 'compute_api_url' => 'http://appwrite-api/v1/compute', - 'compute_api_key' => 'api-key-here', -]; +| Variable | Default | Description | +|----------|---------|-------------| +| `TCP_WORKERS` | `cpu_num * 2` | Worker process count | +| `TCP_SERVER_IMPL` | `swoole` | `swoole` or `coroutine` | +| `TCP_POSTGRES_PORT` | `5432` | PostgreSQL listen port | +| `TCP_MYSQL_PORT` | `3306` | MySQL listen port | +| `TCP_SKIP_VALIDATION` | `false` | Disable SSRF protection | +| `TCP_BACKEND_ENDPOINT` | `tcp-backend:15432` | Default backend endpoint | + +**SMTP Proxy:** + +| Variable | Default | Description | +|----------|---------|-------------| +| `SMTP_BACKEND_ENDPOINT` | `smtp-backend:1025` | Default backend endpoint | +| `SMTP_SKIP_VALIDATION` | `false` | Disable SSRF protection | + +## Testing + +```bash +composer test ``` -## 🎨 Architecture +Integration tests (Docker Compose): + +```bash +composer test:integration +``` + +All tests: + +```bash +composer test:all +``` + +Static analysis: + +```bash +composer check +``` + +## Architecture ``` β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ Protocol Proxy β”‚ +β”‚ Protocol Proxy β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β”‚ +β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ HTTP β”‚ β”‚ TCP β”‚ β”‚ SMTP β”‚ β”‚ β”‚ β”‚ Server β”‚ β”‚ Server β”‚ β”‚ Server β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”‚ β”Œβ”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β” β”‚ β”‚ +β”‚ β”‚ β”‚ TCP β”‚ β”‚ β”‚ +β”‚ β”‚ β”‚ Adapter β”‚ β”‚ β”‚ +β”‚ β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ β”‚ ConnectionMgr β”‚ β”‚ -β”‚ β”‚ (Abstract) β”‚ β”‚ +β”‚ β”‚ Adapter β”‚ β”‚ +β”‚ β”‚ (Base Class) β”‚ β”‚ β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ β”‚ β”‚ β”‚ β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ β”‚ β”‚ β”‚ β”‚ β”‚ -β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”‚ -β”‚ β”‚ Cache β”‚ β”‚ Databaseβ”‚ β”‚ Compute β”‚ β”‚ -β”‚ β”‚ Layer β”‚ β”‚ Pool β”‚ β”‚ API β”‚ β”‚ -β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ +β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”‚ +β”‚ β”‚Resolver β”‚ β”‚ReadWriteβ”‚ β”‚ Query β”‚ β”‚ +β”‚ β”‚(resolve)β”‚ β”‚Resolver β”‚ β”‚ Parser β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ β”‚ β”‚ β”‚ +β”‚ β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β”€β” β”‚ +β”‚ β”‚ Routing β”‚ β”‚ R/W β”‚ β”‚ PG/MY/ β”‚ β”‚ +β”‚ β”‚ Cache β”‚ β”‚ Split β”‚ β”‚ Mongo β”‚ β”‚ +β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ +β”‚ β”‚ +β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ ``` -## πŸ“Š Performance Benchmarks +### Protocol Enum + +The `Protocol` enum defines all supported protocol types: +```php +enum Protocol: string +{ + case HTTP = 'http'; + case SMTP = 'smtp'; + case TCP = 'tcp'; + case PostgreSQL = 'postgresql'; + case MySQL = 'mysql'; + case MongoDB = 'mongodb'; +} ``` -HTTP Proxy: -- Requests/sec: 250,000+ -- Latency p50: <1ms -- Latency p99: <5ms -- Connections: 100,000+ concurrent -TCP Proxy: -- Connections/sec: 100,000+ -- Throughput: 10GB/s+ -- Latency: <1ms forwarding overhead +### Resolver Interface + +The `Resolver` interface is the core abstraction point: -SMTP Proxy: -- Messages/sec: 50,000+ -- Concurrent connections: 50,000+ +```php +interface Resolver +{ + public function resolve(string $resourceId): Result; + public function onConnect(string $resourceId, array $metadata = []): void; + public function onDisconnect(string $resourceId, array $metadata = []): void; + public function track(string $resourceId, array $metadata = []): void; + public function purge(string $resourceId): void; + public function getStats(): array; +} ``` -## πŸ§ͺ Testing +### ReadWriteResolver Interface -```bash -composer test +Extends `Resolver` for read/write split routing: + +```php +interface ReadWriteResolver extends Resolver +{ + public function resolveRead(string $resourceId): Result; + public function resolveWrite(string $resourceId): Result; +} ``` -## πŸ“ License +### Resolution Result + +```php +new Result( + endpoint: 'host:port', // Required: backend endpoint + metadata: ['key' => 'val'], // Optional: additional data + timeout: 30 // Optional: connection timeout override +); +``` + +### Resolution Exceptions + +Use `Resolver\Exception` with appropriate error codes: + +```php +throw new Exception('Not found', Exception::NOT_FOUND); // 404 +throw new Exception('Unavailable', Exception::UNAVAILABLE); // 503 +throw new Exception('Timeout', Exception::TIMEOUT); // 504 +throw new Exception('Forbidden', Exception::FORBIDDEN); // 403 +throw new Exception('Error', Exception::INTERNAL); // 500 +``` + +### Protocol-Specific Routing + +- **HTTP** - Routes requests based on `Host` header +- **TCP/PostgreSQL** - Parses database name from startup message +- **TCP/MySQL** - Extracts database name from COM_INIT_DB packet +- **TCP/MongoDB** - Extracts database name from OP_MSG `$db` field +- **SMTP** - Routes connections based on domain from EHLO/HELO command + +## License BSD-3-Clause diff --git a/benchmarks/README.md b/benchmarks/README.md new file mode 100644 index 0000000..23048ce --- /dev/null +++ b/benchmarks/README.md @@ -0,0 +1,223 @@ +# Benchmarks + +High-load benchmark suite for HTTP and TCP proxies. + +## Validated Performance (8-core, 32GB RAM) + +| Metric | Result | +|--------|--------| +| **Peak concurrent connections** | 672,348 | +| **Memory at peak** | 23 GB | +| **Memory per connection** | ~33 KB | +| **Connection rate (sustained)** | 18,067/sec | +| **CPU at peak** | ~60% | + +## One-Shot Benchmark (Fresh Linux Droplet) + +```bash +curl -sL https://raw.githubusercontent.com/utopia-php/protocol-proxy/dev/benchmarks/bootstrap-droplet.sh | sudo bash +``` + +This installs PHP 8.3 + Swoole, tunes the kernel, and runs all benchmarks automatically. + +## Maximum Connection Stress Test + +```bash +./benchmarks/stress-max.sh +``` + +Pushes the system to maximum concurrent connections. Requires root for kernel tuning. + +## Quick start (HTTP) + +Run the PHP benchmark: +```bash +php benchmarks/http.php +``` + +Run wrk: +```bash +benchmarks/wrk.sh +``` + +Run wrk2 (fixed rate): +```bash +benchmarks/wrk2.sh +``` + +Compare Swoole HTTP servers (evented vs coroutine): +```bash +benchmarks/compare-http-servers.sh +``` + +## Quick start (TCP) + +Run the TCP benchmark: +```bash +php benchmarks/tcp.php +``` + +Compare Swoole TCP servers (evented vs coroutine): +```bash +benchmarks/compare-tcp-servers.sh +``` + +## Presets (HTTP) + +Max throughput, burst: +```bash +WRK_THREADS=16 WRK_CONNECTIONS=5000 WRK_DURATION=30s WRK_URL=http://127.0.0.1:8080/ benchmarks/wrk.sh +``` + +Fixed rate (wrk2): +```bash +WRK2_THREADS=16 WRK2_CONNECTIONS=5000 WRK2_DURATION=30s WRK2_RATE=200000 WRK2_URL=http://127.0.0.1:8080/ benchmarks/wrk2.sh +``` + +PHP benchmark, moderate: +```bash +BENCH_CONCURRENCY=500 BENCH_REQUESTS=50000 php benchmarks/http.php +``` + +## Presets (TCP) + +Connection rate only: +```bash +BENCH_PROTOCOL=mysql BENCH_PORT=15433 BENCH_PAYLOAD_BYTES=0 BENCH_CONCURRENCY=500 BENCH_CONNECTIONS=50000 php benchmarks/tcp.php +``` + +Throughput heavy (payload enabled): +```bash +BENCH_PROTOCOL=mysql BENCH_PORT=15433 BENCH_PAYLOAD_BYTES=65536 BENCH_TARGET_BYTES=17179869184 BENCH_CONCURRENCY=2000 php benchmarks/tcp.php +``` + +## Sustained Load Tests + +Sustained mode (continuous connection churn): +```bash +BENCH_DURATION=300 BENCH_CONCURRENCY=4000 BENCH_PAYLOAD_BYTES=0 php benchmarks/tcp-sustained.php +``` + +Max connections mode (hold connections open): +```bash +BENCH_MODE=max_connections BENCH_TARGET_CONNECTIONS=50000 php benchmarks/tcp-sustained.php +``` + +Hold forever mode (Ctrl+C to stop): +```bash +BENCH_MODE=hold_forever BENCH_TARGET_CONNECTIONS=50000 php benchmarks/tcp-sustained.php +``` + +## Scaling Test (Multiple Backends) + +To test maximum concurrent connections, run multiple backend/client pairs: + +```bash +# Start 16 backends on different ports +for p in $(seq 15432 15447); do + BACKEND_PORT=$p php benchmarks/tcp-backend.php & +done + +# Start 16 clients targeting 40k connections each (640k total) +for p in $(seq 15432 15447); do + BENCH_PORT=$p BENCH_MODE=hold_forever BENCH_TARGET_CONNECTIONS=40000 php benchmarks/tcp-sustained.php & +done + +# Monitor connections +watch -n1 'ss -s | grep estab' +``` + +## Environment variables + +HTTP PHP benchmark (`benchmarks/http.php`): +- `BENCH_HOST` (default `localhost`) +- `BENCH_PORT` (default `8080`) +- `BENCH_CONCURRENCY` (default `max(2000, cpu*500)`) +- `BENCH_REQUESTS` (default `max(1000000, concurrency*500)`) +- `BENCH_TIMEOUT` (default `10`) +- `BENCH_KEEP_ALIVE` (default `true`) +- `BENCH_SAMPLE_TARGET` (default `200000`) +- `BENCH_SAMPLE_EVERY` (optional override) + +TCP PHP benchmark (`benchmarks/tcp.php`): +- `BENCH_HOST` (default `localhost`) +- `BENCH_PORT` (default `5432`) +- `BENCH_PROTOCOL` (`postgres` or `mysql`, default based on port) +- `BENCH_CONCURRENCY` (default `max(2000, cpu*500)`) +- `BENCH_CONNECTIONS` (default derived from payload/target) +- `BENCH_PAYLOAD_BYTES` (default `65536`) +- `BENCH_TARGET_BYTES` (default `8GB`) +- `BENCH_TIMEOUT` (default `10`) +- `BENCH_SAMPLE_TARGET` (default `200000`) +- `BENCH_SAMPLE_EVERY` (optional override) +- `BENCH_PERSISTENT` (default `false`) +- `BENCH_STREAM_BYTES` (default `0`, uses `BENCH_TARGET_BYTES` when persistent) +- `BENCH_STREAM_DURATION` (default `0`) +- `BENCH_ECHO_NEWLINE` (default `false`) + +wrk (`benchmarks/wrk.sh`): +- `WRK_THREADS` (default `cpu`) +- `WRK_CONNECTIONS` (default `1000`) +- `WRK_DURATION` (default `30s`) +- `WRK_URL` (default `http://127.0.0.1:8080/`) +- `WRK_EXTRA` (extra flags) + +wrk2 (`benchmarks/wrk2.sh`): +- `WRK2_THREADS` (default `cpu`) +- `WRK2_CONNECTIONS` (default `1000`) +- `WRK2_DURATION` (default `30s`) +- `WRK2_RATE` (default `50000`) +- `WRK2_URL` (default `http://127.0.0.1:8080/`) +- `WRK2_EXTRA` (extra flags) + +Swoole HTTP compare (`benchmarks/compare-http-servers.sh`): +- `COMPARE_HOST` (default `127.0.0.1`) +- `COMPARE_PORT` (default `8080`) +- `COMPARE_CONCURRENCY` (default `1000`) +- `COMPARE_REQUESTS` (default `100000`) +- `COMPARE_SAMPLE_EVERY` (default `5`) +- `COMPARE_RUNS` (default `1`) +- `COMPARE_BENCH_KEEP_ALIVE` (default `true`) +- `COMPARE_BENCH_TIMEOUT` (default `10`) +- `COMPARE_BACKEND_HOST` (default `127.0.0.1`) +- `COMPARE_BACKEND_PORT` (default `5678`) +- `COMPARE_BACKEND_WORKERS` (optional) +- `COMPARE_WORKERS` (default `8`) +- `COMPARE_DISPATCH_MODE` (default `3`) +- `COMPARE_REACTOR_NUM` (default `16`) +- `COMPARE_BACKEND_POOL_SIZE` (default `2048`) +- `COMPARE_KEEPALIVE_TIMEOUT` (default `10`) +- `COMPARE_OPEN_HTTP2` (default `false`) +- `COMPARE_FAST_ASSUME_OK` (default `true`) +- `COMPARE_SERVER_MODE` (default `base`) + +Swoole TCP compare (`benchmarks/compare-tcp-servers.sh`): +- `COMPARE_HOST` (default `127.0.0.1`) +- `COMPARE_PORT` (default `15433`) +- `COMPARE_PROTOCOL` (default `mysql`) +- `COMPARE_CONCURRENCY` (default `2000`) +- `COMPARE_CONNECTIONS` (default `100000`) +- `COMPARE_PAYLOAD_BYTES` (default `0`) +- `COMPARE_TARGET_BYTES` (default `0`) +- `COMPARE_PERSISTENT` (default `false`) +- `COMPARE_STREAM_BYTES` (default `0`) +- `COMPARE_STREAM_DURATION` (default `0`) +- `COMPARE_ECHO_NEWLINE` (default `false`) +- `COMPARE_TIMEOUT` (default `10`) +- `COMPARE_SAMPLE_EVERY` (default `5`) +- `COMPARE_RUNS` (default `1`) +- `COMPARE_MODE` (`single` or `match`, default `single`) +- `COMPARE_CORO_PROCESSES` (optional override) +- `COMPARE_CORO_REACTOR_NUM` (optional override) +- `COMPARE_BACKEND_HOST` (default `127.0.0.1`) +- `COMPARE_BACKEND_PORT` (default `15432`) +- `COMPARE_BACKEND_WORKERS` (optional) +- `COMPARE_BACKEND_START` (default `true`) +- `COMPARE_WORKERS` (default `8`) +- `COMPARE_REACTOR_NUM` (default `16`) +- `COMPARE_DISPATCH_MODE` (default `2`) + +## Notes + +- For realistic max numbers, run on a tuned Linux host (see `PERFORMANCE.md`). +- Running in Docker on macOS will be bottlenecked by the VM and host networking. diff --git a/benchmarks/bootstrap-droplet.sh b/benchmarks/bootstrap-droplet.sh new file mode 100755 index 0000000..442bed7 --- /dev/null +++ b/benchmarks/bootstrap-droplet.sh @@ -0,0 +1,198 @@ +#!/bin/sh +# +# One-shot benchmark runner for fresh Linux droplet +# +# Usage (as root on fresh Ubuntu 22.04/24.04): +# curl -sL https://raw.githubusercontent.com/utopia-php/protocol-proxy/dev/benchmarks/bootstrap-droplet.sh | sudo bash +# +# Quick Docker test (no install needed): +# docker run --rm --privileged phpswoole/swoole:php8.3-alpine sh -c ' +# apk add --no-cache git composer > /dev/null 2>&1 +# cd /tmp && git clone --depth 1 -b dev https://github.com/utopia-php/protocol-proxy.git +# cd protocol-proxy && composer install --quiet +# BACKEND_HOST=127.0.0.1 BACKEND_PORT=15432 php benchmarks/tcp-backend.php & +# sleep 2 && BENCH_PORT=15432 BENCH_CONCURRENCY=100 BENCH_CONNECTIONS=5000 php benchmarks/tcp.php +# ' +# +set -e + +echo "=== TCP Proxy Benchmark Bootstrap ===" +echo "" + +# Check if running as root +if [ "$(id -u)" -ne 0 ]; then + echo "Error: Run as root (sudo)" + exit 1 +fi + +# Detect OS +if [ -f /etc/os-release ]; then + . /etc/os-release + OS=$ID +else + echo "Error: Cannot detect OS" + exit 1 +fi + +echo "[1/6] Installing dependencies..." + +case "$OS" in + ubuntu|debian) + export DEBIAN_FRONTEND=noninteractive + apt-get update -qq + # Add ondrej PPA for latest PHP + apt-get install -y -qq software-properties-common > /dev/null 2>&1 + add-apt-repository -y ppa:ondrej/php > /dev/null 2>&1 + apt-get update -qq + apt-get install -y -qq php8.3-cli php8.3-dev php8.3-xml php8.3-curl \ + php8.3-mbstring php8.3-zip php-pear git unzip curl > /dev/null 2>&1 + ;; + fedora|rhel|centos|rocky|alma) + dnf install -y -q php-cli php-devel php-xml php-mbstring php-zip \ + git unzip curl > /dev/null 2>&1 + ;; + *) + echo "Warning: Unknown OS '$OS', assuming PHP is installed" + ;; +esac + +echo " - PHP $(php -v | head -1 | cut -d' ' -f2)" + +echo "[2/6] Installing Swoole..." + +# Check if Swoole already installed +if php -m 2>/dev/null | grep -q swoole; then + echo " - Swoole already installed" +else + case "$OS" in + ubuntu|debian) + # Use pre-built package from ondrej PPA (much more reliable than PECL) + apt-get install -y -qq php8.3-swoole > /dev/null 2>&1 || { + echo " - apt package failed, trying PECL..." + # Fallback to PECL + printf "yes\nyes\nno\nno\nno\n" | pecl install swoole > /dev/null 2>&1 || \ + pecl install -f swoole < /dev/null > /dev/null 2>&1 || true + PHP_CONF_DIR=$(php -i 2>/dev/null | grep "Scan this dir" | cut -d'>' -f2 | tr -d ' ') + if [ -n "$PHP_CONF_DIR" ] && [ -d "$PHP_CONF_DIR" ]; then + echo "extension=swoole.so" > "$PHP_CONF_DIR/20-swoole.ini" + fi + } + ;; + *) + # PECL for other distros + printf "yes\nyes\nno\nno\nno\n" | pecl install swoole > /dev/null 2>&1 || \ + pecl install -f swoole < /dev/null > /dev/null 2>&1 || true + PHP_CONF_DIR=$(php -i 2>/dev/null | grep "Scan this dir" | cut -d'>' -f2 | tr -d ' ') + if [ -n "$PHP_CONF_DIR" ] && [ -d "$PHP_CONF_DIR" ]; then + echo "extension=swoole.so" > "$PHP_CONF_DIR/20-swoole.ini" + fi + ;; + esac + echo " - Swoole installed" +fi + +# Verify Swoole +if ! php -m 2>/dev/null | grep -q swoole; then + echo "Error: Swoole not loaded." + echo "" + echo "Manual fix:" + echo " apt-get install php8.3-swoole" + echo "" + echo "Then re-run this script." + exit 1 +fi + +echo "[3/6] Installing Composer..." + +if command -v composer > /dev/null 2>&1; then + echo " - Composer already installed" +else + curl -sS https://getcomposer.org/installer | php -- --quiet --install-dir=/usr/local/bin --filename=composer + echo " - Composer installed" +fi + +echo "[4/6] Cloning protocol-proxy..." + +WORKDIR="/tmp/protocol-proxy-bench" +rm -rf "$WORKDIR" + +if [ -f "composer.json" ] && grep -q "protocol-proxy" composer.json 2>/dev/null; then + # Already in the repo + WORKDIR="$(pwd)" + echo " - Using current directory" +else + git clone --depth 1 -b dev https://github.com/utopia-php/protocol-proxy.git "$WORKDIR" 2>/dev/null + cd "$WORKDIR" + echo " - Cloned to $WORKDIR" +fi + +echo "[5/6] Installing PHP dependencies..." + +composer install --no-interaction --no-progress --quiet 2>/dev/null +echo " - Dependencies installed" + +echo "[6/6] Applying kernel tuning..." + +# Apply benchmark tuning +./benchmarks/setup-linux.sh > /dev/null 2>&1 || { + # Inline tuning if script fails + sysctl -w fs.file-max=2000000 > /dev/null 2>&1 || true + sysctl -w net.core.somaxconn=65535 > /dev/null 2>&1 || true + sysctl -w net.core.rmem_max=134217728 > /dev/null 2>&1 || true + sysctl -w net.core.wmem_max=134217728 > /dev/null 2>&1 || true + sysctl -w net.ipv4.tcp_fastopen=3 > /dev/null 2>&1 || true + sysctl -w net.ipv4.tcp_tw_reuse=1 > /dev/null 2>&1 || true + sysctl -w net.ipv4.ip_local_port_range="1024 65535" > /dev/null 2>&1 || true + ulimit -n 1000000 2>/dev/null || ulimit -n 100000 2>/dev/null || true +} +echo " - Kernel tuned" + +echo "" +echo "=== Bootstrap Complete ===" +echo "" +echo "System info:" +echo " - CPU: $(nproc) cores" +echo " - RAM: $(free -h | awk '/^Mem:/{print $2}')" +echo " - PHP: $(php -v | head -1 | cut -d' ' -f2)" +echo " - Swoole: $(php -r 'echo SWOOLE_VERSION;')" +echo "" +echo "Running benchmarks..." +echo "" + +# Run benchmark +cd "$WORKDIR" + +echo "=== TCP Proxy Benchmark (1M connections burst) ===" +BENCH_PAYLOAD_BYTES=0 \ +BENCH_CONCURRENCY=8000 \ +BENCH_CONNECTIONS=1000000 \ +php benchmarks/tcp.php + +echo "" +echo "=== TCP Proxy Benchmark (throughput 16GB) ===" +BENCH_PAYLOAD_BYTES=65536 \ +BENCH_TARGET_BYTES=17179869184 \ +BENCH_CONCURRENCY=4000 \ +php benchmarks/tcp.php + +echo "" +echo "=== TCP Proxy Benchmark (100k sustained 60s) ===" +BENCH_DURATION=60 \ +BENCH_CONCURRENCY=4000 \ +BENCH_PAYLOAD_BYTES=1024 \ +php benchmarks/tcp-sustained.php + +echo "" +echo "=== Done ===" +echo "" +echo "These are PER-POD numbers. Scale linearly with more pods:" +echo " 5 pods Γ— 100k conn/s = 500k conn/s total" +echo "" +echo "For longer soak test:" +echo " BENCH_DURATION=300 BENCH_CONCURRENCY=4000 php benchmarks/tcp-sustained.php" +echo "" +echo "For max concurrent connections test:" +echo " BENCH_MODE=max_connections BENCH_TARGET_CONNECTIONS=100000 php benchmarks/tcp-sustained.php" +echo "Results above. Re-run with different settings:" +echo " cd $WORKDIR" +echo " BENCH_CONCURRENCY=8000 BENCH_CONNECTIONS=800000 php benchmarks/tcp.php" diff --git a/benchmarks/compare-http-servers.sh b/benchmarks/compare-http-servers.sh new file mode 100755 index 0000000..3c825a3 --- /dev/null +++ b/benchmarks/compare-http-servers.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -euo pipefail + +backend_host=${COMPARE_BACKEND_HOST:-127.0.0.1} +backend_port=${COMPARE_BACKEND_PORT:-5678} +backend_workers=${COMPARE_BACKEND_WORKERS:-} + +host=${COMPARE_HOST:-127.0.0.1} +port=${COMPARE_PORT:-8080} + +concurrency=${COMPARE_CONCURRENCY:-1000} +requests=${COMPARE_REQUESTS:-100000} +sample_every=${COMPARE_SAMPLE_EVERY:-5} +bench_keep_alive=${COMPARE_BENCH_KEEP_ALIVE:-true} +bench_timeout=${COMPARE_BENCH_TIMEOUT:-10} +runs=${COMPARE_RUNS:-1} + +proxy_workers=${COMPARE_WORKERS:-8} +proxy_dispatch=${COMPARE_DISPATCH_MODE:-3} +proxy_reactor=${COMPARE_REACTOR_NUM:-16} +proxy_pool=${COMPARE_BACKEND_POOL_SIZE:-2048} +proxy_keepalive=${COMPARE_KEEPALIVE_TIMEOUT:-10} +proxy_http2=${COMPARE_OPEN_HTTP2:-false} +proxy_fast_assume_ok=${COMPARE_FAST_ASSUME_OK:-true} +proxy_server_mode=${COMPARE_SERVER_MODE:-base} + +cleanup() { + pkill -f "proxies/http.php" >/dev/null 2>&1 || true + pkill -f "php benchmarks/http-backend.php" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +start_backend() { + pkill -f "php benchmarks/http-backend.php" >/dev/null 2>&1 || true + if [ -n "${backend_workers}" ]; then + nohup env BACKEND_HOST="${backend_host}" BACKEND_PORT="${backend_port}" BACKEND_WORKERS="${backend_workers}" \ + php benchmarks/http-backend.php > /tmp/http-backend.log 2>&1 & + else + nohup env BACKEND_HOST="${backend_host}" BACKEND_PORT="${backend_port}" \ + php benchmarks/http-backend.php > /tmp/http-backend.log 2>&1 & + fi + for _ in {1..20}; do + if curl -s -o /dev/null -w "%{http_code}" "http://${backend_host}:${backend_port}/" | grep -q "200"; then + return 0 + fi + sleep 0.25 + done + echo "Backend failed to start" >&2 + return 1 +} + +start_proxy() { + local impl="$1" + pkill -f "proxies/http.php" >/dev/null 2>&1 || true + nohup env \ + HTTP_SERVER_IMPL="${impl}" \ + HTTP_BACKEND_ENDPOINT="${backend_host}:${backend_port}" \ + HTTP_FIXED_BACKEND="${backend_host}:${backend_port}" \ + HTTP_FAST_ASSUME_OK="${proxy_fast_assume_ok}" \ + HTTP_SERVER_MODE="${proxy_server_mode}" \ + HTTP_WORKERS="${proxy_workers}" \ + HTTP_DISPATCH_MODE="${proxy_dispatch}" \ + HTTP_REACTOR_NUM="${proxy_reactor}" \ + HTTP_BACKEND_POOL_SIZE="${proxy_pool}" \ + HTTP_KEEPALIVE_TIMEOUT="${proxy_keepalive}" \ + HTTP_OPEN_HTTP2="${proxy_http2}" \ + php -d memory_limit=1G proxies/http.php > /tmp/http-proxy.log 2>&1 & + + for _ in {1..20}; do + if curl -s -o /dev/null -w "%{http_code}" "http://${host}:${port}/" | grep -q "200"; then + return 0 + fi + sleep 0.25 + done + echo "Proxy failed to start for ${impl}" >&2 + return 1 +} + +run_bench() { + local impl="$1" + local run="$2" + local output + output=$(BENCH_HOST="${host}" BENCH_PORT="${port}" \ + BENCH_CONCURRENCY="${concurrency}" BENCH_REQUESTS="${requests}" \ + BENCH_SAMPLE_EVERY="${sample_every}" BENCH_KEEP_ALIVE="${bench_keep_alive}" \ + BENCH_TIMEOUT="${bench_timeout}" php -d memory_limit=1G benchmarks/http.php) + local throughput + throughput=$(echo "$output" | awk '/Throughput:/ {print $2; exit}') + printf "%s,%s,%s\n" "$impl" "$run" "$throughput" +} + +start_backend + +printf "impl,run,throughput\n" +for impl in swoole coroutine; do + start_proxy "$impl" + for ((i=1; i<=runs; i++)); do + run_bench "$impl" "$i" + done +done diff --git a/benchmarks/compare-tcp-servers.sh b/benchmarks/compare-tcp-servers.sh new file mode 100755 index 0000000..7ab294d --- /dev/null +++ b/benchmarks/compare-tcp-servers.sh @@ -0,0 +1,177 @@ +#!/usr/bin/env bash +set -euo pipefail + +backend_host=${COMPARE_BACKEND_HOST:-127.0.0.1} +backend_port=${COMPARE_BACKEND_PORT:-15432} +backend_workers=${COMPARE_BACKEND_WORKERS:-} +backend_start=${COMPARE_BACKEND_START:-true} +if [ "$backend_start" != "true" ] && [ "$backend_start" != "false" ]; then + backend_start=true +fi + +host=${COMPARE_HOST:-127.0.0.1} +port=${COMPARE_PORT:-15433} +protocol=${COMPARE_PROTOCOL:-mysql} + +mode=${COMPARE_MODE:-single} +if [ "$mode" != "single" ] && [ "$mode" != "match" ]; then + mode=single +fi + +concurrency=${COMPARE_CONCURRENCY:-2000} +connections=${COMPARE_CONNECTIONS:-100000} +payload_bytes=${COMPARE_PAYLOAD_BYTES:-0} +target_bytes=${COMPARE_TARGET_BYTES:-0} +benchmark_timeout=${COMPARE_TIMEOUT:-10} +sample_every=${COMPARE_SAMPLE_EVERY:-5} +runs=${COMPARE_RUNS:-1} +persistent=${COMPARE_PERSISTENT:-false} +stream_bytes=${COMPARE_STREAM_BYTES:-0} +stream_duration=${COMPARE_STREAM_DURATION:-0} +echo_newline=${COMPARE_ECHO_NEWLINE:-false} + +proxy_workers=${COMPARE_WORKERS:-8} +proxy_reactor=${COMPARE_REACTOR_NUM:-} +proxy_dispatch=${COMPARE_DISPATCH_MODE:-2} +coro_processes=${COMPARE_CORO_PROCESSES:-} +coro_reactor=${COMPARE_CORO_REACTOR_NUM:-} + +if [ -z "$proxy_reactor" ]; then + if [ "$mode" = "single" ]; then + proxy_reactor=1 + else + proxy_reactor=16 + fi +fi + +event_workers=$proxy_workers +if [ "$mode" = "single" ]; then + event_workers=1 +fi + +if [ -z "$coro_processes" ]; then + if [ "$mode" = "match" ]; then + coro_processes=$event_workers + else + coro_processes=1 + fi +fi + +if [ -z "$coro_reactor" ]; then + if [ "$mode" = "match" ] && [ "$coro_processes" -gt 1 ]; then + coro_reactor=1 + else + coro_reactor=$proxy_reactor + fi +fi + +cleanup() { + pkill -f "proxies/tcp.php" >/dev/null 2>&1 || true + pkill -f "php benchmarks/tcp-backend.php" >/dev/null 2>&1 || true +} +trap cleanup EXIT + +start_backend() { + if [ "$backend_start" = "false" ]; then + return 0 + fi + + pkill -f "php benchmarks/tcp-backend.php" >/dev/null 2>&1 || true + if [ -n "${backend_workers}" ]; then + nohup env BACKEND_HOST="${backend_host}" BACKEND_PORT="${backend_port}" BACKEND_WORKERS="${backend_workers}" \ + php benchmarks/tcp-backend.php > /tmp/tcp-backend.log 2>&1 & + else + nohup env BACKEND_HOST="${backend_host}" BACKEND_PORT="${backend_port}" \ + php benchmarks/tcp-backend.php > /tmp/tcp-backend.log 2>&1 & + fi + + for _ in {1..20}; do + if php -r '$s=@stream_socket_client("tcp://'"${backend_host}:${backend_port}"'", $errno, $errstr, 0.2); if ($s) { fclose($s); exit(0);} exit(1);' >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "Backend failed to start" >&2 + return 1 +} + +start_proxy() { + local impl="$1" + pkill -f "proxies/tcp.php" >/dev/null 2>&1 || true + for _ in {1..20}; do + if php -r '$s=@stream_socket_client("tcp://'\"${host}:${port}\"'", $errno, $errstr, 0.2); if ($s) { fclose($s); exit(0);} exit(1);' >/dev/null 2>&1; then + sleep 0.25 + else + break + fi + done + if [ "$impl" = "coroutine" ]; then + for _ in $(seq 1 "$coro_processes"); do + nohup env \ + TCP_SERVER_IMPL="${impl}" \ + TCP_BACKEND_ENDPOINT="${backend_host}:${backend_port}" \ + TCP_POSTGRES_PORT="${port}" \ + TCP_MYSQL_PORT=0 \ + TCP_WORKERS=1 \ + TCP_REACTOR_NUM="${coro_reactor}" \ + TCP_DISPATCH_MODE="${proxy_dispatch}" \ + TCP_SKIP_VALIDATION=true \ + php -d memory_limit=1G proxies/tcp.php > /tmp/tcp-proxy.log 2>&1 & + done + else + nohup env \ + TCP_SERVER_IMPL="${impl}" \ + TCP_BACKEND_ENDPOINT="${backend_host}:${backend_port}" \ + TCP_POSTGRES_PORT="${port}" \ + TCP_MYSQL_PORT=0 \ + TCP_WORKERS="${event_workers}" \ + TCP_REACTOR_NUM="${proxy_reactor}" \ + TCP_DISPATCH_MODE="${proxy_dispatch}" \ + TCP_SKIP_VALIDATION=true \ + php -d memory_limit=1G proxies/tcp.php > /tmp/tcp-proxy.log 2>&1 & + fi + + for _ in {1..20}; do + if php -r '$s=@stream_socket_client("tcp://'"${host}:${port}"'", $errno, $errstr, 0.2); if ($s) { fclose($s); exit(0);} exit(1);' >/dev/null 2>&1; then + return 0 + fi + sleep 0.25 + done + echo "Proxy failed to start for ${impl}" >&2 + return 1 +} + +run_bench() { + local impl="$1" + local run="$2" + local output + output=$(BENCH_HOST="${host}" BENCH_PORT="${port}" BENCH_PROTOCOL="${protocol}" \ + BENCH_CONCURRENCY="${concurrency}" BENCH_CONNECTIONS="${connections}" \ + BENCH_PAYLOAD_BYTES="${payload_bytes}" BENCH_TARGET_BYTES="${target_bytes}" \ + BENCH_TIMEOUT="${benchmark_timeout}" BENCH_SAMPLE_EVERY="${sample_every}" \ + BENCH_PERSISTENT="${persistent}" BENCH_STREAM_BYTES="${stream_bytes}" \ + BENCH_STREAM_DURATION="${stream_duration}" BENCH_ECHO_NEWLINE="${echo_newline}" \ + php -d memory_limit=1G benchmarks/tcp.php) + local conn_rate + local throughput + conn_rate=$(echo "$output" | awk '/Connections\/sec:/ {print $2; exit}') + throughput=$(echo "$output" | awk '/Throughput:/ {print $2; exit}') + printf "%s,%s,%s,%s\n" "$impl" "$run" "$conn_rate" "$throughput" +} + +start_backend + +for _ in {1..10}; do + if php -r '$s=@stream_socket_client("tcp://'"${backend_host}:${backend_port}"'", $errno, $errstr, 0.5); if ($s) { fclose($s); exit(0);} exit(1);' >/dev/null 2>&1; then + break + fi + sleep 0.5 +done + +printf "impl,run,connections_per_sec,throughput_gb\n" +for impl in swoole coroutine; do + start_proxy "$impl" + for ((i=1; i<=runs; i++)); do + run_bench "$impl" "$i" + done +done diff --git a/benchmarks/http-backend.php b/benchmarks/http-backend.php new file mode 100644 index 0000000..dfb61f6 --- /dev/null +++ b/benchmarks/http-backend.php @@ -0,0 +1,25 @@ +set([ + 'worker_num' => $workers, + 'max_connection' => 200_000, + 'max_coroutine' => 200_000, + 'enable_coroutine' => true, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'log_level' => SWOOLE_LOG_ERROR, +]); + +$server->on('request', static function (Swoole\Http\Request $request, Swoole\Http\Response $response): void { + $response->header('Content-Type', 'text/plain'); + $response->end('ok'); +}); + +$server->start(); diff --git a/benchmarks/http-benchmark.php b/benchmarks/http-benchmark.php deleted file mode 100644 index b5c5052..0000000 --- a/benchmarks/http-benchmark.php +++ /dev/null @@ -1,107 +0,0 @@ -99% - */ - -use Swoole\Coroutine; -use Swoole\Coroutine\Http\Client; - -Co\run(function () { - echo "HTTP Proxy Benchmark\n"; - echo "===================\n\n"; - - $host = 'localhost'; - $port = 8080; - $concurrent = 1000; - $requests = 100000; - - echo "Configuration:\n"; - echo " Host: {$host}:{$port}\n"; - echo " Concurrent: {$concurrent}\n"; - echo " Total requests: {$requests}\n\n"; - - $startTime = microtime(true); - $latencies = []; - $errors = 0; - $channel = new Coroutine\Channel($concurrent); - - // Spawn concurrent workers - for ($i = 0; $i < $concurrent; $i++) { - Coroutine::create(function () use ($host, $port, $requests, $concurrent, &$latencies, &$errors, $channel) { - $perWorker = (int)($requests / $concurrent); - - for ($j = 0; $j < $perWorker; $j++) { - $reqStart = microtime(true); - - $client = new Client($host, $port); - $client->set(['timeout' => 10]); - $client->get('/'); - - $latency = (microtime(true) - $reqStart) * 1000; - $latencies[] = $latency; - - if ($client->statusCode !== 200) { - $errors++; - } - - $client->close(); - } - - $channel->push(true); - }); - } - - // Wait for all workers to complete - for ($i = 0; $i < $concurrent; $i++) { - $channel->pop(); - } - - $totalTime = microtime(true) - $startTime; - - // Calculate statistics - sort($latencies); - $count = count($latencies); - - $throughput = $requests / $totalTime; - $avgLatency = array_sum($latencies) / $count; - $p50 = $latencies[(int)($count * 0.5)]; - $p95 = $latencies[(int)($count * 0.95)]; - $p99 = $latencies[(int)($count * 0.99)]; - $min = $latencies[0]; - $max = $latencies[$count - 1]; - - echo "\nResults:\n"; - echo "========\n"; - echo sprintf("Total time: %.2fs\n", $totalTime); - echo sprintf("Throughput: %.0f req/s\n", $throughput); - echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $requests) * 100); - echo "\nLatency:\n"; - echo sprintf(" Min: %.2fms\n", $min); - echo sprintf(" Avg: %.2fms\n", $avgLatency); - echo sprintf(" p50: %.2fms\n", $p50); - echo sprintf(" p95: %.2fms\n", $p95); - echo sprintf(" p99: %.2fms\n", $p99); - echo sprintf(" Max: %.2fms\n", $max); - - // Performance goals - echo "\nPerformance Goals:\n"; - echo "==================\n"; - echo sprintf("Throughput goal: 250k+ req/s... %s\n", - $throughput >= 250000 ? "βœ“ PASS" : "βœ— FAIL"); - echo sprintf("p50 latency goal: <1ms... %s\n", - $p50 < 1.0 ? "βœ“ PASS" : "βœ— FAIL"); - echo sprintf("p99 latency goal: <5ms... %s\n", - $p99 < 5.0 ? "βœ“ PASS" : "βœ— FAIL"); -}); diff --git a/benchmarks/http.php b/benchmarks/http.php new file mode 100644 index 0000000..f76a407 --- /dev/null +++ b/benchmarks/http.php @@ -0,0 +1,252 @@ +99% + */ + +use Swoole\Coroutine; +use Swoole\Coroutine\Http\Client; + +Co\run(function () { + echo "HTTP Proxy Benchmark\n"; + echo "===================\n\n"; + + $envInt = static function (string $key, int $default): int { + $value = getenv($key); + + return $value === false ? $default : (int) $value; + }; + $envFloat = static function (string $key, float $default): float { + $value = getenv($key); + + return $value === false ? $default : (float) $value; + }; + $envBool = static function (string $key, bool $default): bool { + $value = getenv($key); + if ($value === false) { + return $default; + } + $parsed = filter_var($value, FILTER_VALIDATE_BOOLEAN, FILTER_NULL_ON_FAILURE); + + return $parsed ?? $default; + }; + + $host = getenv('BENCH_HOST') ?: 'localhost'; + $port = $envInt('BENCH_PORT', 8080); + $cpu = function_exists('swoole_cpu_num') ? swoole_cpu_num() : 4; + $concurrent = $envInt('BENCH_CONCURRENCY', max(2000, $cpu * 500)); + $requests = $envInt('BENCH_REQUESTS', max(1000000, $concurrent * 500)); + $timeout = $envFloat('BENCH_TIMEOUT', 10); + $keepAlive = $envBool('BENCH_KEEP_ALIVE', true); + $sampleTarget = $envInt('BENCH_SAMPLE_TARGET', 200000); + $sampleEvery = $envInt('BENCH_SAMPLE_EVERY', max(1, (int) ceil($requests / max(1, $sampleTarget)))); + + if ($requests < 1) { + echo "Invalid request count.\n"; + + return; + } + if ($concurrent > $requests) { + $concurrent = $requests; + } + if ($concurrent < 1) { + echo "Invalid concurrency.\n"; + + return; + } + + echo "Configuration:\n"; + echo " Host: {$host}:{$port}\n"; + echo " Concurrent: {$concurrent}\n"; + echo " Total requests: {$requests}\n"; + echo ' Keep-alive: '.($keepAlive ? 'yes' : 'no')."\n"; + echo " Sample every: {$sampleEvery} req\n\n"; + + $startTime = microtime(true); + $errors = 0; + $channel = new Coroutine\Channel($concurrent); + $perWorker = intdiv($requests, $concurrent); + $remainder = $requests % $concurrent; + + // Spawn concurrent workers + for ($i = 0; $i < $concurrent; $i++) { + $workerRequests = $perWorker + ($i < $remainder ? 1 : 0); + Coroutine::create(function () use ( + $host, + $port, + $workerRequests, + $timeout, + $keepAlive, + $sampleEvery, + $channel + ) { + $count = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $errors = 0; + $samples = []; + + if ($workerRequests < 1) { + $channel->push([ + 'count' => 0, + 'sum' => 0.0, + 'min' => INF, + 'max' => 0.0, + 'errors' => 0, + 'samples' => [], + ]); + + return; + } + + $createClient = static function () use ($host, $port, $timeout, $keepAlive): Client { + $client = new Client($host, $port); + $client->set([ + 'timeout' => $timeout, + 'keep_alive' => $keepAlive, + ]); + $client->setHeaders(['Host' => $host]); + + return $client; + }; + + $client = $keepAlive ? $createClient() : null; + + for ($j = 0; $j < $workerRequests; $j++) { + if ($keepAlive && $client === null) { + $client = $createClient(); + } + + $reqStart = microtime(true); + + if ($keepAlive) { + $ok = $client->get('/'); + $status = $client->statusCode; + } else { + $client = $createClient(); + $ok = $client->get('/'); + $status = $client->statusCode; + $client->close(); + } + + $latency = (microtime(true) - $reqStart) * 1000; + $count++; + $sum += $latency; + + if ($latency < $min) { + $min = $latency; + } + if ($latency > $max) { + $max = $latency; + } + if (($count % $sampleEvery) === 0) { + $samples[] = $latency; + } + + if ($ok === false || $status !== 200) { + $errors++; + if ($keepAlive && $client !== null) { + $client->close(); + $client = null; + } + } + } + + if ($keepAlive && $client !== null) { + $client->close(); + } + + $channel->push([ + 'count' => $count, + 'sum' => $sum, + 'min' => $min, + 'max' => $max, + 'errors' => $errors, + 'samples' => $samples, + ]); + }); + } + + $totalCount = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $samples = []; + + for ($i = 0; $i < $concurrent; $i++) { + $result = $channel->pop(); + $totalCount += $result['count']; + $sum += $result['sum']; + $errors += $result['errors']; + if ($result['count'] > 0) { + if ($result['min'] < $min) { + $min = $result['min']; + } + if ($result['max'] > $max) { + $max = $result['max']; + } + } + if (! empty($result['samples'])) { + $samples = array_merge($samples, $result['samples']); + } + } + + $totalTime = microtime(true) - $startTime; + + // Calculate statistics + if ($totalCount === 0) { + echo "No requests completed.\n"; + + return; + } + + $throughput = $totalCount / $totalTime; + $avgLatency = $sum / $totalCount; + + sort($samples); + $sampleCount = count($samples); + $p50 = $sampleCount ? $samples[(int) floor($sampleCount * 0.5)] : 0.0; + $p95 = $sampleCount ? $samples[(int) floor($sampleCount * 0.95)] : 0.0; + $p99 = $sampleCount ? $samples[(int) floor($sampleCount * 0.99)] : 0.0; + + echo "\nResults:\n"; + echo "========\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Throughput: %.0f req/s\n", $throughput); + echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $totalCount) * 100); + echo "\nLatency (sampled):\n"; + echo sprintf(" Min: %.2fms\n", $min); + echo sprintf(" Avg: %.2fms\n", $avgLatency); + echo sprintf(" p50: %.2fms\n", $p50); + echo sprintf(" p95: %.2fms\n", $p95); + echo sprintf(" p99: %.2fms\n", $p99); + echo sprintf(" Max: %.2fms\n", $max); + + // Performance goals + echo "\nPerformance Goals:\n"; + echo "==================\n"; + echo sprintf( + "Throughput goal: 250k+ req/s... %s\n", + $throughput >= 250000 ? 'βœ“ PASS' : 'βœ— FAIL' + ); + echo sprintf( + "p50 latency goal: <1ms... %s\n", + $p50 < 1.0 ? 'βœ“ PASS' : 'βœ— FAIL' + ); + echo sprintf( + "p99 latency goal: <5ms... %s\n", + $p99 < 5.0 ? 'βœ“ PASS' : 'βœ— FAIL' + ); +}); diff --git a/benchmarks/setup-linux-production.sh b/benchmarks/setup-linux-production.sh new file mode 100755 index 0000000..dad667d --- /dev/null +++ b/benchmarks/setup-linux-production.sh @@ -0,0 +1,180 @@ +#!/bin/sh +# +# Linux Production Tuning for TCP Proxy +# +# Run as root: sudo ./setup-linux-production.sh +# +# Conservative settings safe for production database proxies. +# Optimizes for reliability + performance, not max benchmark numbers. +# +set -e + +PERSIST=0 +if [ "$1" = "--persist" ]; then + PERSIST=1 +fi + +if [ "$(id -u)" -ne 0 ]; then + echo "Error: This script must be run as root (sudo)" + exit 1 +fi + +echo "=== Linux TCP Proxy Production Tuning ===" +echo "" + +SYSCTL_FILE="/etc/sysctl.d/99-tcp-proxy-prod.conf" + +# ----------------------------------------------------------------------------- +# 1. File Descriptor Limits (safe, just capacity) +# ----------------------------------------------------------------------------- +echo "[1/5] Setting file descriptor limits..." + +sysctl -w fs.file-max=1000000 >/dev/null +sysctl -w fs.nr_open=1000000 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> /etc/security/limits.conf << 'EOF' +# TCP Proxy Production Tuning +* soft nofile 1000000 +* hard nofile 1000000 +root soft nofile 1000000 +root hard nofile 1000000 +EOF + echo "fs.file-max = 1000000" >> "$SYSCTL_FILE" + echo "fs.nr_open = 1000000" >> "$SYSCTL_FILE" +fi + +echo " - fs.file-max = 1000000" + +# ----------------------------------------------------------------------------- +# 2. TCP Connection Backlog (safe, prevents SYN drops) +# ----------------------------------------------------------------------------- +echo "[2/5] Tuning TCP connection backlog..." + +sysctl -w net.core.somaxconn=32768 >/dev/null +sysctl -w net.ipv4.tcp_max_syn_backlog=32768 >/dev/null +sysctl -w net.core.netdev_max_backlog=32768 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> "$SYSCTL_FILE" << 'EOF' +net.core.somaxconn = 32768 +net.ipv4.tcp_max_syn_backlog = 32768 +net.core.netdev_max_backlog = 32768 +EOF +fi + +echo " - net.core.somaxconn = 32768" + +# ----------------------------------------------------------------------------- +# 3. Socket Buffer Sizes (safe, just memory) +# ----------------------------------------------------------------------------- +echo "[3/5] Tuning socket buffer sizes..." + +sysctl -w net.core.rmem_max=67108864 >/dev/null +sysctl -w net.core.wmem_max=67108864 >/dev/null +sysctl -w net.ipv4.tcp_rmem="4096 87380 33554432" >/dev/null +sysctl -w net.ipv4.tcp_wmem="4096 65536 33554432" >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> "$SYSCTL_FILE" << 'EOF' +net.core.rmem_max = 67108864 +net.core.wmem_max = 67108864 +net.ipv4.tcp_rmem = 4096 87380 33554432 +net.ipv4.tcp_wmem = 4096 65536 33554432 +EOF +fi + +echo " - Buffer max = 64MB" + +# ----------------------------------------------------------------------------- +# 4. TCP Optimizations (conservative, production-safe) +# ----------------------------------------------------------------------------- +echo "[4/5] Enabling TCP optimizations..." + +# TCP Fast Open - safe, optional feature +sysctl -w net.ipv4.tcp_fastopen=3 >/dev/null + +# TIME_WAIT handling - conservative +sysctl -w net.ipv4.tcp_fin_timeout=30 >/dev/null # Default is 60, 30 is safe +sysctl -w net.ipv4.tcp_tw_reuse=1 >/dev/null # Safe for proxies + +# Keep defaults for these (safer): +# tcp_slow_start_after_idle = 1 (default) - prevents burst on congested networks +# tcp_no_metrics_save = 0 (default) - keeps learned route metrics + +# Standard optimizations +sysctl -w net.ipv4.tcp_window_scaling=1 >/dev/null +sysctl -w net.ipv4.tcp_sack=1 >/dev/null + +# Port range +sysctl -w net.ipv4.ip_local_port_range="1024 65535" >/dev/null + +# Orphan/TIME_WAIT limits +sysctl -w net.ipv4.tcp_max_orphans=65536 >/dev/null +sysctl -w net.ipv4.tcp_max_tw_buckets=500000 >/dev/null + +# Keepalive - detect dead connections faster +sysctl -w net.ipv4.tcp_keepalive_time=300 >/dev/null +sysctl -w net.ipv4.tcp_keepalive_intvl=30 >/dev/null +sysctl -w net.ipv4.tcp_keepalive_probes=5 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> "$SYSCTL_FILE" << 'EOF' +net.ipv4.tcp_fastopen = 3 +net.ipv4.tcp_fin_timeout = 30 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_window_scaling = 1 +net.ipv4.tcp_sack = 1 +net.ipv4.ip_local_port_range = 1024 65535 +net.ipv4.tcp_max_orphans = 65536 +net.ipv4.tcp_max_tw_buckets = 500000 +net.ipv4.tcp_keepalive_time = 300 +net.ipv4.tcp_keepalive_intvl = 30 +net.ipv4.tcp_keepalive_probes = 5 +EOF +fi + +echo " - tcp_fastopen = 3" +echo " - tcp_fin_timeout = 30s" +echo " - tcp_tw_reuse = 1" +echo " - tcp_keepalive = 300s/30s/5 probes" + +# ----------------------------------------------------------------------------- +# 5. Memory (conservative) +# ----------------------------------------------------------------------------- +echo "[5/5] Tuning memory settings..." + +sysctl -w net.ipv4.tcp_mem="524288 786432 1048576" >/dev/null +sysctl -w vm.max_map_count=262144 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> "$SYSCTL_FILE" << 'EOF' +net.ipv4.tcp_mem = 524288 786432 1048576 +vm.max_map_count = 262144 +EOF +fi + +echo " - tcp_mem = 2GB/3GB/4GB" + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +echo "" +echo "=== Production Tuning Complete ===" +echo "" +echo "Current limits:" +echo " - File descriptors: $(ulimit -n)" +echo " - Max connections: $(sysctl -n net.core.somaxconn)" +echo " - Local ports: $(sysctl -n net.ipv4.ip_local_port_range)" +echo "" + +if [ $PERSIST -eq 1 ]; then + echo "Settings persisted to $SYSCTL_FILE" +else + echo "Settings are temporary. Run with --persist for permanent." +fi + +echo "" +echo "Production-safe settings applied." +echo "For benchmarking, use setup-linux.sh instead." +echo "" diff --git a/benchmarks/setup-linux.sh b/benchmarks/setup-linux.sh new file mode 100755 index 0000000..9d5dbb3 --- /dev/null +++ b/benchmarks/setup-linux.sh @@ -0,0 +1,228 @@ +#!/bin/sh +# +# Linux Performance Tuning for TCP Proxy Benchmarks +# +# Run as root: sudo ./setup-linux.sh +# +# This script optimizes the system for high-throughput, low-latency TCP proxying. +# Changes are temporary (until reboot) unless you pass --persist +# +set -e + +PERSIST=0 +if [ "$1" = "--persist" ]; then + PERSIST=1 +fi + +# Check if running as root +if [ "$(id -u)" -ne 0 ]; then + echo "Error: This script must be run as root (sudo)" + exit 1 +fi + +echo "=== Linux TCP Proxy Performance Tuning ===" +echo "" + +# ----------------------------------------------------------------------------- +# 1. File Descriptor Limits +# ----------------------------------------------------------------------------- +echo "[1/6] Setting file descriptor limits..." + +# Current session +ulimit -n 2000000 2>/dev/null || ulimit -n 1000000 2>/dev/null || ulimit -n 500000 + +# System-wide +sysctl -w fs.file-max=2000000 >/dev/null +sysctl -w fs.nr_open=2000000 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> /etc/security/limits.conf << 'EOF' +# TCP Proxy Performance Tuning +* soft nofile 2000000 +* hard nofile 2000000 +root soft nofile 2000000 +root hard nofile 2000000 +EOF + echo "fs.file-max = 2000000" >> /etc/sysctl.d/99-tcp-proxy.conf + echo "fs.nr_open = 2000000" >> /etc/sysctl.d/99-tcp-proxy.conf +fi + +echo " - fs.file-max = 2000000" +echo " - fs.nr_open = 2000000" + +# ----------------------------------------------------------------------------- +# 2. TCP Connection Backlog +# ----------------------------------------------------------------------------- +echo "[2/6] Tuning TCP connection backlog..." + +sysctl -w net.core.somaxconn=65535 >/dev/null +sysctl -w net.ipv4.tcp_max_syn_backlog=65535 >/dev/null +sysctl -w net.core.netdev_max_backlog=65535 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> /etc/sysctl.d/99-tcp-proxy.conf << 'EOF' +net.core.somaxconn = 65535 +net.ipv4.tcp_max_syn_backlog = 65535 +net.core.netdev_max_backlog = 65535 +EOF +fi + +echo " - net.core.somaxconn = 65535" +echo " - net.ipv4.tcp_max_syn_backlog = 65535" +echo " - net.core.netdev_max_backlog = 65535" + +# ----------------------------------------------------------------------------- +# 3. Socket Buffer Sizes +# ----------------------------------------------------------------------------- +echo "[3/6] Tuning socket buffer sizes..." + +# Max buffer sizes (128MB) +sysctl -w net.core.rmem_max=134217728 >/dev/null +sysctl -w net.core.wmem_max=134217728 >/dev/null + +# TCP buffer auto-tuning: min, default, max +sysctl -w net.ipv4.tcp_rmem="4096 87380 67108864" >/dev/null +sysctl -w net.ipv4.tcp_wmem="4096 65536 67108864" >/dev/null + +# Default socket buffer sizes +sysctl -w net.core.rmem_default=262144 >/dev/null +sysctl -w net.core.wmem_default=262144 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> /etc/sysctl.d/99-tcp-proxy.conf << 'EOF' +net.core.rmem_max = 134217728 +net.core.wmem_max = 134217728 +net.ipv4.tcp_rmem = 4096 87380 67108864 +net.ipv4.tcp_wmem = 4096 65536 67108864 +net.core.rmem_default = 262144 +net.core.wmem_default = 262144 +EOF +fi + +echo " - net.core.rmem_max = 128MB" +echo " - net.core.wmem_max = 128MB" +echo " - net.ipv4.tcp_rmem = 4KB/85KB/64MB" +echo " - net.ipv4.tcp_wmem = 4KB/64KB/64MB" + +# ----------------------------------------------------------------------------- +# 4. TCP Performance Optimizations +# ----------------------------------------------------------------------------- +echo "[4/6] Enabling TCP performance optimizations..." + +# Enable TCP Fast Open (client + server) +sysctl -w net.ipv4.tcp_fastopen=3 >/dev/null + +# Reduce TIME_WAIT sockets +sysctl -w net.ipv4.tcp_fin_timeout=10 >/dev/null +sysctl -w net.ipv4.tcp_tw_reuse=1 >/dev/null + +# Disable slow start after idle (keep cwnd high) +sysctl -w net.ipv4.tcp_slow_start_after_idle=0 >/dev/null + +# Don't cache TCP metrics (each connection starts fresh) +sysctl -w net.ipv4.tcp_no_metrics_save=1 >/dev/null + +# Enable TCP window scaling +sysctl -w net.ipv4.tcp_window_scaling=1 >/dev/null + +# Enable selective acknowledgments +sysctl -w net.ipv4.tcp_sack=1 >/dev/null + +# Increase local port range +sysctl -w net.ipv4.ip_local_port_range="1024 65535" >/dev/null + +# Allow more orphan sockets +sysctl -w net.ipv4.tcp_max_orphans=262144 >/dev/null + +# Increase max TIME_WAIT sockets +sysctl -w net.ipv4.tcp_max_tw_buckets=2000000 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> /etc/sysctl.d/99-tcp-proxy.conf << 'EOF' +net.ipv4.tcp_fastopen = 3 +net.ipv4.tcp_fin_timeout = 10 +net.ipv4.tcp_tw_reuse = 1 +net.ipv4.tcp_slow_start_after_idle = 0 +net.ipv4.tcp_no_metrics_save = 1 +net.ipv4.tcp_window_scaling = 1 +net.ipv4.tcp_sack = 1 +net.ipv4.ip_local_port_range = 1024 65535 +net.ipv4.tcp_max_orphans = 262144 +net.ipv4.tcp_max_tw_buckets = 2000000 +EOF +fi + +echo " - tcp_fastopen = 3 (client+server)" +echo " - tcp_fin_timeout = 10s" +echo " - tcp_tw_reuse = 1" +echo " - tcp_slow_start_after_idle = 0" +echo " - ip_local_port_range = 1024-65535" + +# ----------------------------------------------------------------------------- +# 5. Memory Optimizations +# ----------------------------------------------------------------------------- +echo "[5/6] Tuning memory settings..." + +# TCP memory limits: min, pressure, max (in pages, 4KB each) +sysctl -w net.ipv4.tcp_mem="786432 1048576 1572864" >/dev/null + +# Disable swap for consistent performance (optional, be careful) +# sysctl -w vm.swappiness=0 >/dev/null + +# Increase max memory map areas +sysctl -w vm.max_map_count=262144 >/dev/null + +if [ $PERSIST -eq 1 ]; then + cat >> /etc/sysctl.d/99-tcp-proxy.conf << 'EOF' +net.ipv4.tcp_mem = 786432 1048576 1572864 +vm.max_map_count = 262144 +EOF +fi + +echo " - tcp_mem = 3GB/4GB/6GB" +echo " - vm.max_map_count = 262144" + +# ----------------------------------------------------------------------------- +# 6. Optional: Disable CPU Frequency Scaling (for benchmarks) +# ----------------------------------------------------------------------------- +echo "[6/6] Checking CPU governor..." + +if [ -f /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor ]; then + CURRENT_GOV=$(cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor) + echo " - Current governor: $CURRENT_GOV" + + if [ "$CURRENT_GOV" != "performance" ]; then + echo " - Setting governor to 'performance' for all CPUs..." + for cpu in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do + echo "performance" > "$cpu" 2>/dev/null || true + done + echo " - Done (temporary, resets on reboot)" + fi +else + echo " - CPU frequency scaling not available" +fi + +# ----------------------------------------------------------------------------- +# Summary +# ----------------------------------------------------------------------------- +echo "" +echo "=== Tuning Complete ===" +echo "" +echo "Current limits:" +echo " - File descriptors: $(ulimit -n)" +echo " - Max connections: $(sysctl -n net.core.somaxconn)" +echo " - Local ports: $(sysctl -n net.ipv4.ip_local_port_range)" +echo "" + +if [ $PERSIST -eq 1 ]; then + echo "Settings persisted to /etc/sysctl.d/99-tcp-proxy.conf" + echo "Run 'sysctl -p /etc/sysctl.d/99-tcp-proxy.conf' to reload" +else + echo "Settings are temporary (lost on reboot)" + echo "Run with --persist to make permanent" +fi + +echo "" +echo "Ready to benchmark! Run:" +echo " BENCH_CONCURRENCY=4000 BENCH_CONNECTIONS=400000 php benchmarks/tcp.php" +echo "" diff --git a/benchmarks/stress-max.sh b/benchmarks/stress-max.sh new file mode 100644 index 0000000..aed8bb1 --- /dev/null +++ b/benchmarks/stress-max.sh @@ -0,0 +1,169 @@ +#!/bin/bash +# +# Maximum connection stress test +# Pushes as many concurrent connections as possible on a single node +# +# Usage: ./benchmarks/stress-max.sh +# + +set -e + +# Configuration +NUM_BACKENDS=16 +CONNECTIONS_PER_CLIENT=40000 +BASE_PORT=15432 +REPORT_INTERVAL=3 + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' + +echo "==============================================" +echo " TCP Proxy Maximum Connection Stress Test" +echo "==============================================" +echo "" + +# Check if running as root +if [ "$(id -u)" -ne 0 ]; then + echo -e "${RED}Error: Run as root for kernel tuning${NC}" + exit 1 +fi + +# System info +CORES=$(nproc) +RAM_GB=$(free -g | awk '/^Mem:/{print $2}') +echo "System: ${CORES} cores, ${RAM_GB}GB RAM" + +# Calculate targets based on RAM (42KB per connection, leave 4GB headroom) +MAX_CONNECTIONS=$(( (RAM_GB - 4) * 1024 * 1024 / 42 )) +TARGET_CONNECTIONS=$(( NUM_BACKENDS * CONNECTIONS_PER_CLIENT )) +if [ $TARGET_CONNECTIONS -gt $MAX_CONNECTIONS ]; then + TARGET_CONNECTIONS=$MAX_CONNECTIONS + CONNECTIONS_PER_CLIENT=$(( TARGET_CONNECTIONS / NUM_BACKENDS )) +fi + +echo "Target: ${TARGET_CONNECTIONS} connections (${NUM_BACKENDS} backends Γ— ${CONNECTIONS_PER_CLIENT} each)" +echo "" + +# Cleanup +echo "[1/4] Cleaning up..." +pkill -f 'php.*benchmark' 2>/dev/null || true +pkill -f 'php.*tcp-backend' 2>/dev/null || true +sleep 1 + +# Kernel tuning +echo "[2/4] Applying kernel tuning..." +sysctl -w fs.file-max=2000000 > /dev/null +sysctl -w fs.nr_open=2000000 > /dev/null +sysctl -w net.core.somaxconn=65535 > /dev/null +sysctl -w net.ipv4.tcp_max_syn_backlog=65535 > /dev/null +sysctl -w net.ipv4.ip_local_port_range="1024 65535" > /dev/null +sysctl -w net.ipv4.tcp_tw_reuse=1 > /dev/null +sysctl -w net.ipv4.tcp_fin_timeout=10 > /dev/null +sysctl -w net.core.netdev_max_backlog=65535 > /dev/null +sysctl -w net.core.rmem_max=134217728 > /dev/null +sysctl -w net.core.wmem_max=134217728 > /dev/null +ulimit -n 1000000 + +# Start backends +echo "[3/4] Starting ${NUM_BACKENDS} backend servers..." +cd "$(dirname "$0")/.." + +for i in $(seq 0 $((NUM_BACKENDS - 1))); do + port=$((BASE_PORT + i)) + BACKEND_PORT=$port php benchmarks/tcp-backend.php > /dev/null 2>&1 & +done +sleep 2 + +# Verify backends started +RUNNING_BACKENDS=$(pgrep -f tcp-backend | wc -l) +if [ "$RUNNING_BACKENDS" -lt "$NUM_BACKENDS" ]; then + echo -e "${RED}Warning: Only ${RUNNING_BACKENDS}/${NUM_BACKENDS} backends started${NC}" +fi + +# Start benchmark clients +echo "[4/4] Starting ${NUM_BACKENDS} benchmark clients..." +for i in $(seq 0 $((NUM_BACKENDS - 1))); do + port=$((BASE_PORT + i)) + BENCH_PORT=$port \ + BENCH_MODE=hold_forever \ + BENCH_TARGET_CONNECTIONS=$CONNECTIONS_PER_CLIENT \ + BENCH_REPORT_INTERVAL=9999 \ + php benchmarks/tcp-sustained.php > /dev/null 2>&1 & +done + +echo "" +echo "==============================================" +echo " Live Stats (Ctrl+C to stop)" +echo "==============================================" +echo "" + +# Monitor loop +START_TIME=$(date +%s) +PEAK_CONNECTIONS=0 + +cleanup() { + echo "" + echo "" + echo "==============================================" + echo " Final Results" + echo "==============================================" + echo "" + echo "Peak connections: ${PEAK_CONNECTIONS}" + echo "Memory used: $(free -h | awk '/^Mem:/{print $3}')" + echo "" + echo "Cleaning up..." + pkill -f 'php.*benchmark' 2>/dev/null || true + pkill -f 'php.*tcp-backend' 2>/dev/null || true + exit 0 +} + +trap cleanup INT TERM + +printf "%-10s | %-12s | %-10s | %-10s | %-8s | %-10s\n" \ + "Time" "Connections" "Target" "Memory" "CPU%" "Status" +printf "%-10s-+-%-12s-+-%-10s-+-%-10s-+-%-8s-+-%-10s\n" \ + "----------" "------------" "----------" "----------" "--------" "----------" + +while true; do + ELAPSED=$(( $(date +%s) - START_TIME )) + + # Get current connections (divide by 2 for localhost) + TCP_INFO=$(ss -s 2>/dev/null | grep "^TCP:" | head -1) + TOTAL_SOCKETS=$(echo "$TCP_INFO" | awk '{print $2}') + ESTAB=$(echo "$TCP_INFO" | grep -oP 'estab \K[0-9]+' || echo "0") + CONNECTIONS=$((ESTAB / 2)) + + # Update peak + if [ "$CONNECTIONS" -gt "$PEAK_CONNECTIONS" ]; then + PEAK_CONNECTIONS=$CONNECTIONS + fi + + # Memory + MEM_USED=$(free -h | awk '/^Mem:/{print $3}') + + # CPU + CPU=$(top -bn1 | grep "Cpu(s)" | awk '{print $2}' | cut -d'%' -f1) + + # Status + if [ "$CONNECTIONS" -ge "$TARGET_CONNECTIONS" ]; then + STATUS="${GREEN}REACHED${NC}" + elif [ "$CONNECTIONS" -ge $((TARGET_CONNECTIONS * 90 / 100)) ]; then + STATUS="${YELLOW}CLOSE${NC}" + else + STATUS="RAMPING" + fi + + # Format time + MINS=$((ELAPSED / 60)) + SECS=$((ELAPSED % 60)) + TIME_FMT=$(printf "%02d:%02d" $MINS $SECS) + + printf "\r%-10s | %-12s | %-10s | %-10s | %-8s | " \ + "$TIME_FMT" "$CONNECTIONS" "$TARGET_CONNECTIONS" "$MEM_USED" "${CPU}%" + echo -e "$STATUS" + + sleep $REPORT_INTERVAL +done diff --git a/benchmarks/tcp-backend.php b/benchmarks/tcp-backend.php new file mode 100644 index 0000000..81ac5ea --- /dev/null +++ b/benchmarks/tcp-backend.php @@ -0,0 +1,35 @@ +set([ + 'worker_num' => $workers, + 'reactor_num' => $reactorNum, + 'max_connection' => 200_000, + 'max_coroutine' => 200_000, + 'enable_coroutine' => true, + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'enable_reuse_port' => true, + 'backlog' => $backlog, + 'log_level' => SWOOLE_LOG_ERROR, +]); + +$server->on('receive', static function (Swoole\Server $server, int $fd, int $reactorId, string $data): void { + $server->send($fd, $data); +}); + +$server->start(); diff --git a/benchmarks/tcp-benchmark.php b/benchmarks/tcp-benchmark.php deleted file mode 100644 index 07dc76c..0000000 --- a/benchmarks/tcp-benchmark.php +++ /dev/null @@ -1,110 +0,0 @@ -connect($host, $port, 10)) { - $errors++; - continue; - } - - // Send PostgreSQL startup message - $data = pack('N', 196608); // Protocol version 3.0 - $data .= "user\0postgres\0database\0db-abc123\0\0"; - - $client->send($data); - $response = $client->recv(8192, 5); - - $latency = (microtime(true) - $connStart) * 1000; - $latencies[] = $latency; - - $client->close(); - } - - $channel->push(true); - }); - } - - // Wait for all workers to complete - for ($i = 0; $i < $concurrent; $i++) { - $channel->pop(); - } - - $totalTime = microtime(true) - $startTime; - - // Calculate statistics - sort($latencies); - $count = count($latencies); - - $connPerSec = $connections / $totalTime; - $avgLatency = array_sum($latencies) / $count; - $p50 = $latencies[(int)($count * 0.5)]; - $p95 = $latencies[(int)($count * 0.95)]; - $p99 = $latencies[(int)($count * 0.99)]; - $min = $latencies[0]; - $max = $latencies[$count - 1]; - - echo "\nResults:\n"; - echo "========\n"; - echo sprintf("Total time: %.2fs\n", $totalTime); - echo sprintf("Connections/sec: %.0f\n", $connPerSec); - echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $connections) * 100); - echo "\nLatency:\n"; - echo sprintf(" Min: %.2fms\n", $min); - echo sprintf(" Avg: %.2fms\n", $avgLatency); - echo sprintf(" p50: %.2fms\n", $p50); - echo sprintf(" p95: %.2fms\n", $p95); - echo sprintf(" p99: %.2fms\n", $p99); - echo sprintf(" Max: %.2fms\n", $max); - - // Performance goals - echo "\nPerformance Goals:\n"; - echo "==================\n"; - echo sprintf("Connections/sec goal: 100k+... %s\n", - $connPerSec >= 100000 ? "βœ“ PASS" : "βœ— FAIL"); - echo sprintf("Forwarding overhead goal: <1ms... %s\n", - $avgLatency < 1.0 ? "βœ“ PASS" : "βœ— FAIL"); -}); diff --git a/benchmarks/tcp-sustained.php b/benchmarks/tcp-sustained.php new file mode 100755 index 0000000..8ff7c73 --- /dev/null +++ b/benchmarks/tcp-sustained.php @@ -0,0 +1,351 @@ +#!/usr/bin/env php + 0 ? str_repeat('x', $payloadBytes) : ''; + + echo "Configuration:\n"; + echo " Host: {$host}:{$port}\n"; + echo " Mode: {$mode}\n"; + if ($mode === 'sustained') { + echo " Duration: {$duration}s\n"; + echo " Concurrency: {$concurrency}\n"; + echo " Payload: {$payloadBytes} bytes\n"; + } else { + echo " Target connections: {$targetConnections}\n"; + } + echo " Report interval: {$reportInterval}s\n"; + echo "\n"; + + // Shared stats (using Swoole atomic for thread safety) + $stats = [ + 'connections' => new Swoole\Atomic(0), + 'requests' => new Swoole\Atomic(0), + 'errors' => new Swoole\Atomic(0), + 'bytes_sent' => new Swoole\Atomic\Long(0), + 'bytes_recv' => new Swoole\Atomic\Long(0), + 'active' => new Swoole\Atomic(0), + 'latency_sum' => new Swoole\Atomic\Long(0), + 'latency_count' => new Swoole\Atomic(0), + 'latency_max' => new Swoole\Atomic(0), + ]; + + $running = new Swoole\Atomic(1); + $startTime = microtime(true); + $lastReportTime = $startTime; + $lastStats = [ + 'connections' => 0, + 'requests' => 0, + 'errors' => 0, + 'bytes_sent' => 0, + 'bytes_recv' => 0, + ]; + + // Reporter coroutine + Coroutine::create(function () use ($stats, $running, &$lastReportTime, &$lastStats, $startTime, $reportInterval, $duration, $mode) { + $reportNum = 0; + + echo "Time | Conn/s | Req/s | Err/s | Active | Throughput | Latency p50 | Memory\n"; + echo "---------|--------|--------|-------|--------|------------|-------------|--------\n"; + + while ($running->get() === 1) { + Coroutine::sleep($reportInterval); + $reportNum++; + + $now = microtime(true); + $elapsed = $now - $startTime; + $interval = $now - $lastReportTime; + + $currentConnections = $stats['connections']->get(); + $currentRequests = $stats['requests']->get(); + $currentErrors = $stats['errors']->get(); + $currentBytesSent = $stats['bytes_sent']->get(); + $currentBytesRecv = $stats['bytes_recv']->get(); + $active = $stats['active']->get(); + + $connPerSec = ($currentConnections - $lastStats['connections']) / $interval; + $reqPerSec = ($currentRequests - $lastStats['requests']) / $interval; + $errPerSec = ($currentErrors - $lastStats['errors']) / $interval; + $throughput = (($currentBytesSent - $lastStats['bytes_sent']) + ($currentBytesRecv - $lastStats['bytes_recv'])) / $interval / 1024 / 1024; + + // Calculate average latency (rough p50 approximation) + $latencyCount = $stats['latency_count']->get(); + $latencySum = $stats['latency_sum']->get(); + $avgLatency = $latencyCount > 0 ? ($latencySum / $latencyCount / 1000) : 0; // convert to ms + + $memory = memory_get_usage(true) / 1024 / 1024; + + printf( + "%7.1fs | %6.0f | %6.0f | %5.0f | %6d | %8.2f MB/s | %9.2f ms | %5.1f MB\n", + $elapsed, + $connPerSec, + $reqPerSec, + $errPerSec, + $active, + $throughput, + $avgLatency, + $memory + ); + + $lastStats = [ + 'connections' => $currentConnections, + 'requests' => $currentRequests, + 'errors' => $currentErrors, + 'bytes_sent' => $currentBytesSent, + 'bytes_recv' => $currentBytesRecv, + ]; + $lastReportTime = $now; + + // Reset latency stats each interval for rolling average + $stats['latency_sum']->set(0); + $stats['latency_count']->set(0); + + // Check duration + if ($mode === 'sustained' && $elapsed >= $duration) { + $running->set(0); + } + } + }); + + if ($mode === 'max_connections' || $mode === 'hold_forever') { + // Max connections test: open connections and hold them + echo "Opening {$targetConnections} connections...\n"; + if ($mode === 'hold_forever') { + echo "(Hold forever mode - Ctrl+C to stop)\n"; + } + echo "\n"; + + $clients = []; + $batchSize = 1000; + + for ($batch = 0; $batch < ceil($targetConnections / $batchSize); $batch++) { + $batchStart = $batch * $batchSize; + $batchEnd = min($batchStart + $batchSize, $targetConnections); + + for ($i = $batchStart; $i < $batchEnd; $i++) { + Coroutine::create(function () use ($host, $port, $timeout, $handshake, $stats, $running, &$clients, $i) { + $client = new Client(SWOOLE_SOCK_TCP); + $client->set(['timeout' => $timeout]); + + if (!$client->connect($host, $port, $timeout)) { + $stats['errors']->add(1); + return; + } + + $stats['connections']->add(1); + $stats['active']->add(1); + + // Send handshake + if ($client->send($handshake) === false) { + $stats['errors']->add(1); + $stats['active']->sub(1); + $client->close(); + return; + } + + // Receive response + $client->recv(8192); + + $clients[$i] = $client; + + // Hold connection until test ends + while ($running->get() === 1) { + Coroutine::sleep(1); + + // Periodic ping to keep alive + if ($client->send("PING") === false) { + break; + } + $client->recv(1024); + $stats['requests']->add(1); + } + + $stats['active']->sub(1); + $client->close(); + }); + } + + // Small delay between batches + Coroutine::sleep(0.1); + } + + // Wait for target or timeout + $maxWait = 300; // 5 minutes to open connections + $waited = 0; + while ($stats['active']->get() < $targetConnections && $waited < $maxWait && $running->get() === 1) { + Coroutine::sleep(1); + $waited++; + } + + echo "\n"; + echo "=== Max Connections Result ===\n"; + echo "Target: {$targetConnections}\n"; + echo "Achieved: {$stats['active']->get()}\n"; + echo "Errors: {$stats['errors']->get()}\n"; + + // Hold for observation + if ($mode === 'hold_forever') { + echo "\nHolding connections indefinitely (Ctrl+C to stop)...\n"; + while ($running->get() === 1) { + Coroutine::sleep(60); + } + } else { + echo "\nHolding connections for 30 seconds...\n"; + Coroutine::sleep(30); + $running->set(0); + } + + } else { + // Sustained load test: continuous requests + echo "Starting sustained load...\n\n"; + + for ($i = 0; $i < $concurrency; $i++) { + Coroutine::create(function () use ($host, $port, $timeout, $handshake, $payload, $payloadBytes, $stats, $running) { + while ($running->get() === 1) { + $requestStart = hrtime(true); + + $client = new Client(SWOOLE_SOCK_TCP); + $client->set(['timeout' => $timeout]); + + if (!$client->connect($host, $port, $timeout)) { + $stats['errors']->add(1); + Coroutine::sleep(0.01); // Back off on error + continue; + } + + $stats['connections']->add(1); + $stats['active']->add(1); + + // Send handshake + if ($client->send($handshake) === false) { + $stats['errors']->add(1); + $stats['active']->sub(1); + $client->close(); + continue; + } + $stats['bytes_sent']->add(strlen($handshake)); + + // Receive handshake response + $response = $client->recv(8192); + if ($response === false || $response === '') { + $stats['errors']->add(1); + $stats['active']->sub(1); + $client->close(); + continue; + } + $stats['bytes_recv']->add(strlen($response)); + + // Send payload and receive echo + if ($payloadBytes > 0) { + if ($client->send($payload) === false) { + $stats['errors']->add(1); + } else { + $stats['bytes_sent']->add($payloadBytes); + $echo = $client->recv($payloadBytes + 1024); + if ($echo !== false) { + $stats['bytes_recv']->add(strlen($echo)); + } + } + } + + $stats['requests']->add(1); + $stats['active']->sub(1); + $client->close(); + + // Track latency + $latencyUs = (hrtime(true) - $requestStart) / 1000; // microseconds + $stats['latency_sum']->add((int) $latencyUs); + $stats['latency_count']->add(1); + } + }); + } + + // Wait for duration + Coroutine::sleep($duration + 1); + $running->set(0); + } + + // Wait for reporters to finish + Coroutine::sleep($reportInterval + 1); + + // Final summary + $totalTime = microtime(true) - $startTime; + $totalConnections = $stats['connections']->get(); + $totalRequests = $stats['requests']->get(); + $totalErrors = $stats['errors']->get(); + $totalBytesSent = $stats['bytes_sent']->get(); + $totalBytesRecv = $stats['bytes_recv']->get(); + + echo "\n"; + echo "=== Final Summary ===\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Total connections: %d\n", $totalConnections); + echo sprintf("Total requests: %d\n", $totalRequests); + echo sprintf("Total errors: %d (%.2f%%)\n", $totalErrors, $totalConnections > 0 ? ($totalErrors / $totalConnections * 100) : 0); + echo sprintf("Avg connections/sec: %.2f\n", $totalConnections / $totalTime); + echo sprintf("Avg requests/sec: %.2f\n", $totalRequests / $totalTime); + echo sprintf("Total data transferred: %.2f MB\n", ($totalBytesSent + $totalBytesRecv) / 1024 / 1024); + echo sprintf("Peak memory: %.2f MB\n", memory_get_peak_usage(true) / 1024 / 1024); + echo "\n"; + + // Pass/fail criteria + $errorRate = $totalConnections > 0 ? ($totalErrors / $totalConnections * 100) : 100; + echo "=== Stability Check ===\n"; + echo sprintf("Error rate < 1%%: %s (%.2f%%)\n", $errorRate < 1 ? 'βœ“ PASS' : 'βœ— FAIL', $errorRate); + echo sprintf("Memory stable: %s\n", memory_get_peak_usage(true) < 1024 * 1024 * 1024 ? 'βœ“ PASS' : 'βœ— FAIL (>1GB)'); +}); diff --git a/benchmarks/tcp.php b/benchmarks/tcp.php new file mode 100644 index 0000000..faacc70 --- /dev/null +++ b/benchmarks/tcp.php @@ -0,0 +1,532 @@ + 0) { + $connections = max(100000, $concurrent * 20); + $maxByTarget = (int) floor($targetBytes / max(1, $payloadBytes)); + if ($maxByTarget > 0) { + $connections = min($connections, $maxByTarget); + } + } + } else { + $connections = (int) $connectionsEnv; + } + $sampleTarget = $envInt('BENCH_SAMPLE_TARGET', 200000); + $sampleEvery = $envInt('BENCH_SAMPLE_EVERY', max(1, (int) ceil($connections / max(1, $sampleTarget)))); + + if ($connections < 1) { + echo "Invalid connection count.\n"; + + return; + } + if ($concurrent > $connections) { + $concurrent = $connections; + } + if ($concurrent < 1) { + echo "Invalid concurrency.\n"; + + return; + } + + echo "Configuration:\n"; + echo " Host: {$host}:{$port}\n"; + echo " Concurrent: {$concurrent}\n"; + echo " Total connections: {$connections}\n"; + echo " Protocol: {$protocol}\n"; + echo " Payload per connection: {$payloadBytes} bytes\n"; + echo " Sample every: {$sampleEvery} conns\n\n"; + + $startTime = microtime(true); + $errors = 0; + $channel = new Coroutine\Channel($concurrent); + $perWorker = intdiv($connections, $concurrent); + $remainder = $connections % $concurrent; + + $chunkSize = 65536; + $payloadChunk = ''; + $payloadRemainder = ''; + $payloadSuffix = ''; + $payloadDataBytes = $payloadBytes; + if ($echoNewline && $payloadBytes > 0) { + $payloadDataBytes = $payloadBytes - 1; + $payloadSuffix = "\n"; + } + if ($payloadBytes > 0) { + $chunkSize = min($chunkSize, max(1, $payloadDataBytes)); + $payloadChunk = $payloadDataBytes > 0 ? str_repeat('a', $chunkSize) : ''; + $remainderBytes = $payloadDataBytes % $chunkSize; + if ($remainderBytes > 0) { + $payloadRemainder = str_repeat('a', $remainderBytes); + } + } + + $handshake = ''; + if ($protocol === 'mysql') { + // Minimal COM_INIT_DB packet; adapter only checks command byte + db name. + $handshake = "\x00\x00\x00\x00\x02db-abc123"; + } else { + // PostgreSQL startup message + $handshake = pack('N', 196608); // Protocol version 3.0 + $handshake .= "user\0postgres\0database\0db-abc123\0\0"; + } + if ($echoNewline && $protocol === 'mysql') { + $handshake .= "\n"; + } + + if ($persistent) { + if ($payloadBytes <= 0) { + echo "Persistent mode requires BENCH_PAYLOAD_BYTES > 0.\n"; + + return; + } + + echo "Mode: persistent\n"; + if ($streamBytes > 0) { + echo " Stream bytes: {$streamBytes}\n"; + } + if ($streamDuration > 0) { + echo " Stream duration: {$streamDuration}s\n"; + } + echo "\n"; + + $remainingBytes = null; + if ($streamBytes > 0) { + if (class_exists('Swoole\\Atomic\\Long')) { + $remainingBytes = new \Swoole\Atomic\Long($streamBytes); + } else { + $remainingBytes = new \Swoole\Atomic($streamBytes); + } + } + $deadline = $streamDuration > 0 ? (microtime(true) + $streamDuration) : null; + + $startTime = microtime(true); + $errors = 0; + $channel = new Coroutine\Channel($concurrent); + + for ($i = 0; $i < $concurrent; $i++) { + Coroutine::create(function () use ( + $host, + $port, + $timeout, + $payloadBytes, + $payloadDataBytes, + $payloadChunk, + $payloadRemainder, + $payloadSuffix, + $handshake, + $remainingBytes, + $deadline, + $channel + ) { + $bytes = 0; + $ops = 0; + $errors = 0; + + $client = new Client(SWOOLE_SOCK_TCP); + $client->set(['timeout' => $timeout]); + + if (! $client->connect($host, $port, $timeout)) { + $errors++; + $channel->push([ + 'bytes' => 0, + 'ops' => 0, + 'errors' => $errors, + ]); + + return; + } + + if ($client->send($handshake) === false) { + $errors++; + $client->close(); + $channel->push([ + 'bytes' => 0, + 'ops' => 0, + 'errors' => $errors, + ]); + + return; + } + + $handshakeResponse = $client->recv(8192); + if ($handshakeResponse === '' || $handshakeResponse === false) { + $errors++; + $client->close(); + $channel->push([ + 'bytes' => 0, + 'ops' => 0, + 'errors' => $errors, + ]); + + return; + } + + while (true) { + if ($deadline !== null && microtime(true) >= $deadline) { + break; + } + + $chunkBytes = $payloadBytes; + $payload = $payloadChunk; + $payloadTail = $payloadSuffix; + + if ($remainingBytes !== null) { + $remaining = $remainingBytes->get(); + if ($remaining <= 0) { + break; + } + $chunkBytes = min($payloadBytes, $remaining); + $remainingBytes->sub($chunkBytes); + if ($chunkBytes !== $payloadBytes) { + $payloadTail = ''; + $payload = $chunkBytes > 0 ? substr($payloadChunk, 0, $chunkBytes) : ''; + } + } + + if ($chunkBytes <= 0) { + break; + } + + $remainingSend = $payloadDataBytes > 0 ? min($payloadDataBytes, $chunkBytes) : 0; + $remainingData = $remainingSend; + while ($remainingData > 0) { + if ($remainingData > strlen($payloadChunk)) { + if ($client->send($payloadChunk) === false) { + $errors++; + break 2; + } + $remainingData -= strlen($payloadChunk); + } else { + $chunk = $payloadRemainder !== '' ? $payloadRemainder : substr($payloadChunk, 0, $remainingData); + if ($client->send($chunk) === false) { + $errors++; + break 2; + } + $remainingData = 0; + } + } + if ($payloadTail !== '') { + if ($client->send($payloadTail) === false) { + $errors++; + break; + } + } + + $received = 0; + while ($received < $chunkBytes) { + $chunk = $client->recv(min(65536, $chunkBytes - $received)); + if ($chunk === '' || $chunk === false) { + $errors++; + break 2; + } + $received += strlen($chunk); + } + + $bytes += $chunkBytes; + $ops++; + } + + $client->close(); + + $channel->push([ + 'bytes' => $bytes, + 'ops' => $ops, + 'errors' => $errors, + ]); + }); + } + + $totalBytes = 0; + $totalOps = 0; + + for ($i = 0; $i < $concurrent; $i++) { + $result = $channel->pop(); + $totalBytes += $result['bytes']; + $totalOps += $result['ops']; + $errors += $result['errors']; + } + + $totalTime = microtime(true) - $startTime; + if ($totalTime <= 0) { + $totalTime = 0.0001; + } + + $throughput = $totalBytes / $totalTime; + $throughputGb = $throughput / (1024 * 1024 * 1024); + $opsPerSec = $totalOps / $totalTime; + $connPerSec = $connections / $totalTime; + + echo "\nResults:\n"; + echo "========\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Connections/sec: %.2f\n", $connPerSec); + echo sprintf("Ops/sec: %.2f\n", $opsPerSec); + echo sprintf("Throughput: %.4f GB/s\n", $throughputGb); + echo sprintf("Errors: %d\n", $errors); + + return; + } + + // Spawn concurrent workers + for ($i = 0; $i < $concurrent; $i++) { + $workerConnections = $perWorker + ($i < $remainder ? 1 : 0); + Coroutine::create(function () use ( + $host, + $port, + $workerConnections, + $timeout, + $payloadBytes, + $payloadDataBytes, + $payloadChunk, + $payloadRemainder, + $payloadSuffix, + $sampleEvery, + $handshake, + $channel + ) { + $count = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $errors = 0; + $bytes = 0; + $samples = []; + + if ($workerConnections < 1) { + $channel->push([ + 'count' => 0, + 'sum' => 0.0, + 'min' => INF, + 'max' => 0.0, + 'errors' => 0, + 'bytes' => 0, + 'samples' => [], + ]); + + return; + } + + for ($j = 0; $j < $workerConnections; $j++) { + $connStart = microtime(true); + + $client = new Client(SWOOLE_SOCK_TCP); + $client->set([ + 'timeout' => $timeout, + ]); + + if (! $client->connect($host, $port, $timeout)) { + $errors++; + $latency = (microtime(true) - $connStart) * 1000; + $count++; + $sum += $latency; + if ($latency < $min) { + $min = $latency; + } + if ($latency > $max) { + $max = $latency; + } + if (($count % $sampleEvery) === 0) { + $samples[] = $latency; + } + + continue; + } + + $client->send($handshake); + $response = $client->recv(8192); + + if ($payloadBytes > 0) { + $remaining = $payloadDataBytes; + while ($remaining > 0) { + if ($remaining > strlen($payloadChunk)) { + $client->send($payloadChunk); + $remaining -= strlen($payloadChunk); + } else { + $chunk = $payloadRemainder !== '' ? $payloadRemainder : substr($payloadChunk, 0, $remaining); + $client->send($chunk); + $remaining = 0; + } + } + if ($payloadSuffix !== '') { + $client->send($payloadSuffix); + } + + $received = 0; + while ($received < $payloadBytes) { + $chunk = $client->recv(min(65536, $payloadBytes - $received)); + if ($chunk === '' || $chunk === false) { + $errors++; + break; + } + $received += strlen($chunk); + } + $bytes += $received; + } + + $latency = (microtime(true) - $connStart) * 1000; + $count++; + $sum += $latency; + + if ($latency < $min) { + $min = $latency; + } + if ($latency > $max) { + $max = $latency; + } + if (($count % $sampleEvery) === 0) { + $samples[] = $latency; + } + + if ($response === '' || $response === false) { + $errors++; + } + + $client->close(); + } + + $channel->push([ + 'count' => $count, + 'sum' => $sum, + 'min' => $min, + 'max' => $max, + 'errors' => $errors, + 'bytes' => $bytes, + 'samples' => $samples, + ]); + }); + } + + $totalCount = 0; + $sum = 0.0; + $min = INF; + $max = 0.0; + $bytes = 0; + $samples = []; + + for ($i = 0; $i < $concurrent; $i++) { + $result = $channel->pop(); + $totalCount += $result['count']; + $sum += $result['sum']; + $errors += $result['errors']; + $bytes += $result['bytes']; + if ($result['count'] > 0) { + if ($result['min'] < $min) { + $min = $result['min']; + } + if ($result['max'] > $max) { + $max = $result['max']; + } + } + if (! empty($result['samples'])) { + $samples = array_merge($samples, $result['samples']); + } + } + + $totalTime = microtime(true) - $startTime; + + // Calculate statistics + if ($totalCount === 0) { + echo "No connections completed.\n"; + + return; + } + + $connPerSec = $totalCount / $totalTime; + $avgLatency = $sum / $totalCount; + + sort($samples); + $sampleCount = count($samples); + $p50 = $sampleCount ? $samples[(int) floor($sampleCount * 0.5)] : 0.0; + $p95 = $sampleCount ? $samples[(int) floor($sampleCount * 0.95)] : 0.0; + $p99 = $sampleCount ? $samples[(int) floor($sampleCount * 0.99)] : 0.0; + $throughputGb = $bytes > 0 ? ($bytes / $totalTime / 1024 / 1024 / 1024) : 0.0; + + echo "\nResults:\n"; + echo "========\n"; + echo sprintf("Total time: %.2fs\n", $totalTime); + echo sprintf("Connections/sec: %.0f\n", $connPerSec); + if ($bytes > 0) { + echo sprintf("Throughput: %.2f GB/s\n", $throughputGb); + } + echo sprintf("Errors: %d (%.2f%%)\n", $errors, ($errors / $totalCount) * 100); + echo "\nLatency (sampled):\n"; + echo sprintf(" Min: %.2fms\n", $min); + echo sprintf(" Avg: %.2fms\n", $avgLatency); + echo sprintf(" p50: %.2fms\n", $p50); + echo sprintf(" p95: %.2fms\n", $p95); + echo sprintf(" p99: %.2fms\n", $p99); + echo sprintf(" Max: %.2fms\n", $max); + + // Performance goals + echo "\nPerformance Goals:\n"; + echo "==================\n"; + echo sprintf( + "Connections/sec goal: 100k+... %s\n", + $connPerSec >= 100000 ? 'βœ“ PASS' : 'βœ— FAIL' + ); + echo sprintf( + "Forwarding overhead goal: <1ms... %s\n", + $avgLatency < 1.0 ? 'βœ“ PASS' : 'βœ— FAIL' + ); +}); diff --git a/benchmarks/test-bootstrap.sh b/benchmarks/test-bootstrap.sh new file mode 100755 index 0000000..b82561d --- /dev/null +++ b/benchmarks/test-bootstrap.sh @@ -0,0 +1,94 @@ +#!/bin/sh +# +# Dry-run test for bootstrap script - checks each step without running benchmarks +# +set -e + +echo "=== Testing Bootstrap Script ===" + +# Check if running as root +if [ "$(id -u)" -ne 0 ]; then + echo "Error: Run as root (sudo)" + exit 1 +fi + +echo "[1/6] Testing package manager..." +if command -v apt-get > /dev/null 2>&1; then + echo " OK: apt-get available" + apt-get update -qq +elif command -v dnf > /dev/null 2>&1; then + echo " OK: dnf available" +else + echo " FAIL: No supported package manager" + exit 1 +fi + +echo "[2/6] Testing PHP installation..." +export DEBIAN_FRONTEND=noninteractive + +# Try installing PHP +apt-get install -y -qq software-properties-common > /dev/null 2>&1 || true +add-apt-repository -y ppa:ondrej/php > /dev/null 2>&1 || true +apt-get update -qq > /dev/null 2>&1 + +if apt-get install -y -qq php8.3-cli php8.3-dev php8.3-xml php8.3-curl php8.3-mbstring php8.3-zip > /dev/null 2>&1; then + echo " OK: PHP 8.3 installed" +elif apt-get install -y -qq php8.2-cli php8.2-dev php8.2-xml php8.2-curl php8.2-mbstring php8.2-zip > /dev/null 2>&1; then + echo " OK: PHP 8.2 installed" +else + echo " FAIL: Could not install PHP" + exit 1 +fi + +php -v | head -1 + +echo "[3/6] Testing pecl/Swoole..." +apt-get install -y -qq php-pear php8.3-dev 2>/dev/null || apt-get install -y -qq php-pear php8.2-dev 2>/dev/null || true + +if php -m | grep -q swoole; then + echo " OK: Swoole already loaded" +else + echo " Installing Swoole via pecl..." + printf "\n\n\n\n\n\n" | pecl install swoole > /dev/null 2>&1 + + # Enable extension + PHP_INI_DIR=$(php -i | grep "Scan this dir" | cut -d'>' -f2 | tr -d ' ') + if [ -n "$PHP_INI_DIR" ] && [ -d "$PHP_INI_DIR" ]; then + echo "extension=swoole.so" > "$PHP_INI_DIR/20-swoole.ini" + fi + + if php -m | grep -q swoole; then + echo " OK: Swoole installed and loaded" + else + echo " FAIL: Swoole not loading" + echo " Debug: php -m output:" + php -m | grep -i swoole || echo " (not found)" + exit 1 + fi +fi + +echo "[4/6] Testing Composer..." +apt-get install -y -qq git unzip curl > /dev/null 2>&1 +curl -sS https://getcomposer.org/installer | php -- --quiet --install-dir=/usr/local/bin --filename=composer +echo " OK: Composer $(composer --version 2>/dev/null | cut -d' ' -f3)" + +echo "[5/6] Testing git clone..." +cd /tmp +rm -rf protocol-proxy-test +git clone --depth 1 -b dev https://github.com/utopia-php/protocol-proxy.git protocol-proxy-test > /dev/null 2>&1 +cd protocol-proxy-test +echo " OK: Cloned successfully" + +echo "[6/6] Testing composer install..." +composer install --no-interaction --no-progress --quiet +echo " OK: Dependencies installed" + +echo "" +echo "=== All Checks Passed ===" +echo "" +echo "Quick benchmark test (10 connections):" +BENCH_CONCURRENCY=5 BENCH_CONNECTIONS=10 BENCH_PAYLOAD_BYTES=0 php benchmarks/tcp.php + +echo "" +echo "Bootstrap script should work. Run the full version:" +echo " curl -sL https://raw.githubusercontent.com/utopia-php/protocol-proxy/dev/benchmarks/bootstrap-droplet.sh | sudo bash" diff --git a/benchmarks/wrk.sh b/benchmarks/wrk.sh new file mode 100755 index 0000000..0edb66c --- /dev/null +++ b/benchmarks/wrk.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v wrk >/dev/null 2>&1; then + echo "wrk not found. Install wrk or set WRK_BIN." >&2 + exit 1 +fi + +cpu_count() { + if command -v nproc >/dev/null 2>&1; then + nproc + return + fi + if command -v getconf >/dev/null 2>&1; then + getconf _NPROCESSORS_ONLN + return + fi + if command -v sysctl >/dev/null 2>&1; then + sysctl -n hw.ncpu 2>/dev/null || echo 4 + return + fi + echo 4 +} + +threads="${WRK_THREADS:-$(cpu_count)}" +connections="${WRK_CONNECTIONS:-1000}" +duration="${WRK_DURATION:-30s}" +url="${WRK_URL:-http://127.0.0.1:8080/}" + +extra_args=() +if [[ -n "${WRK_EXTRA:-}" ]]; then + read -r -a extra_args <<< "${WRK_EXTRA}" +fi + +echo "Running: wrk -t${threads} -c${connections} -d${duration} ${extra_args[*]} ${url}" +exec wrk -t"${threads}" -c"${connections}" -d"${duration}" "${extra_args[@]}" "${url}" diff --git a/benchmarks/wrk2.sh b/benchmarks/wrk2.sh new file mode 100755 index 0000000..7475377 --- /dev/null +++ b/benchmarks/wrk2.sh @@ -0,0 +1,37 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v wrk2 >/dev/null 2>&1; then + echo "wrk2 not found. Install wrk2 or set WRK2_BIN." >&2 + exit 1 +fi + +cpu_count() { + if command -v nproc >/dev/null 2>&1; then + nproc + return + fi + if command -v getconf >/dev/null 2>&1; then + getconf _NPROCESSORS_ONLN + return + fi + if command -v sysctl >/dev/null 2>&1; then + sysctl -n hw.ncpu 2>/dev/null || echo 4 + return + fi + echo 4 +} + +threads="${WRK2_THREADS:-$(cpu_count)}" +connections="${WRK2_CONNECTIONS:-1000}" +duration="${WRK2_DURATION:-30s}" +rate="${WRK2_RATE:-50000}" +url="${WRK2_URL:-http://127.0.0.1:8080/}" + +extra_args=() +if [[ -n "${WRK2_EXTRA:-}" ]]; then + read -r -a extra_args <<< "${WRK2_EXTRA}" +fi + +echo "Running: wrk2 -t${threads} -c${connections} -d${duration} -R${rate} ${extra_args[*]} ${url}" +exec wrk2 -t"${threads}" -c"${connections}" -d"${duration}" -R"${rate}" "${extra_args[@]}" "${url}" diff --git a/composer.json b/composer.json index d33279d..235b279 100644 --- a/composer.json +++ b/composer.json @@ -1,5 +1,5 @@ { - "name": "appwrite/protocol-proxy", + "name": "utopia-php/protocol-proxy", "description": "High-performance protocol-agnostic proxy with Swoole for HTTP, TCP, and SMTP", "type": "library", "license": "BSD-3-Clause", @@ -10,35 +10,49 @@ } ], "require": { - "php": ">=8.2", - "ext-swoole": ">=5.0", + "php": ">=8.4", + "ext-swoole": ">=6.0", "ext-redis": "*", - "utopia-php/database": "4.*", + "utopia-php/query": "dev-feat-builder" }, "require-dev": { - "phpunit/phpunit": "^10.0", - "phpstan/phpstan": "^1.10", - "laravel/pint": "^1.13" + "phpunit/phpunit": "12.*", + "phpstan/phpstan": "*", + "laravel/pint": "*" }, "autoload": { "psr-4": { - "Appwrite\\ProtocolProxy\\": "src/" + "Utopia\\Proxy\\": "src/" } }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Utopia\\Tests\\": "tests/" } }, "scripts": { - "test": "phpunit", - "lint": "pint", - "analyse": "phpstan analyse" + "bench:http": "php benchmarks/http.php", + "bench:tcp": "php benchmarks/tcp.php", + "bench:wrk": "bash benchmarks/wrk.sh", + "bench:wrk2": "bash benchmarks/wrk2.sh", + "bench:compare": "bash benchmarks/compare-http-servers.sh", + "bench:compare-tcp": "bash benchmarks/compare-tcp-servers.sh", + "test": "phpunit --testsuite Unit", + "test:integration": "phpunit --testsuite Integration", + "test:all": "phpunit", + "lint": "pint --test --config=pint.json", + "format": "pint --config=pint.json", + "check": "phpstan analyse --level=max --memory-limit=2G src tests" }, "config": { + "php": "8.4", "optimize-autoloader": true, - "sort-packages": true + "sort-packages": true, + "allow-plugins": { + "php-http/discovery": true, + "tbachert/spi": true + } }, - "minimum-stability": "stable", + "minimum-stability": "dev", "prefer-stable": true } diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..d7f1bfa --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,26 @@ +version: '3.8' + +# Development override for docker-compose +# Usage: docker-compose -f docker-compose.yml -f docker-compose.dev.yml up + +services: + http-proxy: + volumes: + - ./src:/app/src:ro + - ./examples:/app/examples:ro + environment: + - SWOOLE_LOG_LEVEL=debug + + tcp-proxy: + volumes: + - ./src:/app/src:ro + - ./examples:/app/examples:ro + environment: + - SWOOLE_LOG_LEVEL=debug + + smtp-proxy: + volumes: + - ./src:/app/src:ro + - ./examples:/app/examples:ro + environment: + - SWOOLE_LOG_LEVEL=debug diff --git a/docker-compose.integration.yml b/docker-compose.integration.yml new file mode 100644 index 0000000..e61497d --- /dev/null +++ b/docker-compose.integration.yml @@ -0,0 +1,42 @@ +services: + http-backend: + image: nginx:1.27-alpine + container_name: protocol-proxy-http-backend + command: ["sh", "-c", "printf 'server { listen 5678; location / { root /usr/share/nginx/html; index index.html; } }' > /etc/nginx/conf.d/default.conf && echo -n ok > /usr/share/nginx/html/index.html && nginx -g 'daemon off;'"] + networks: + - protocol-proxy + + tcp-backend: + image: alpine/socat + container_name: protocol-proxy-tcp-backend + command: ["-v", "TCP-LISTEN:15432,reuseaddr,fork", "SYSTEM:cat"] + networks: + - protocol-proxy + + smtp-backend: + image: axllent/mailpit:v1.19.0 + container_name: protocol-proxy-smtp-backend + command: ["--smtp", "0.0.0.0:1025", "--listen", "0.0.0.0:8025"] + networks: + - protocol-proxy + + http-proxy: + environment: + HTTP_BACKEND_ENDPOINT: http-backend:5678 + HTTP_SKIP_VALIDATION: "true" + depends_on: + - http-backend + + tcp-proxy: + environment: + TCP_BACKEND_ENDPOINT: tcp-backend:15432 + TCP_SKIP_VALIDATION: "true" + depends_on: + - tcp-backend + + smtp-proxy: + environment: + SMTP_BACKEND_ENDPOINT: smtp-backend:1025 + SMTP_SKIP_VALIDATION: "true" + depends_on: + - smtp-backend diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..3557e52 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,118 @@ +services: + + mariadb: + image: mariadb:11.2 + container_name: protocol-proxy-mariadb + restart: unless-stopped + environment: + MYSQL_ROOT_PASSWORD: rootpassword + MYSQL_DATABASE: appwrite + MYSQL_USER: appwrite + MYSQL_PASSWORD: password + ports: + - "${MARIADB_PORT:-3306}:3306" + volumes: + - mariadb_data:/var/lib/mysql + networks: + - protocol-proxy + healthcheck: + test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + container_name: protocol-proxy-redis + restart: unless-stopped + ports: + - "${REDIS_PORT:-6379}:6379" + volumes: + - redis_data:/data + networks: + - protocol-proxy + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + http-proxy: + build: . + container_name: protocol-proxy-http + restart: unless-stopped + ports: + - "${HTTP_PROXY_PORT:-8080}:8080" + environment: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: appwrite + DB_PASS: password + DB_NAME: appwrite + REDIS_HOST: redis + REDIS_PORT: 6379 + COMPUTE_API_URL: http://appwrite/v1/compute + COMPUTE_API_KEY: "" + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - protocol-proxy + command: php proxies/http.php + + tcp-proxy: + build: . + container_name: protocol-proxy-tcp + restart: unless-stopped + ports: + - "${TCP_POSTGRES_PORT:-5432}:5432" + - "${TCP_MYSQL_PORT:-3306}:3306" + environment: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: appwrite + DB_PASS: password + DB_NAME: appwrite + REDIS_HOST: redis + REDIS_PORT: 6379 + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - protocol-proxy + command: php proxies/tcp.php + + smtp-proxy: + build: . + container_name: protocol-proxy-smtp + restart: unless-stopped + ports: + - "${SMTP_PROXY_PORT:-8025}:25" + environment: + DB_HOST: mariadb + DB_PORT: 3306 + DB_USER: appwrite + DB_PASS: password + DB_NAME: appwrite + REDIS_HOST: redis + REDIS_PORT: 6379 + depends_on: + mariadb: + condition: service_healthy + redis: + condition: service_healthy + networks: + - protocol-proxy + command: php proxies/smtp.php + +networks: + protocol-proxy: + driver: bridge + +volumes: + mariadb_data: + redis_data: diff --git a/examples/http-edge-integration.php b/examples/http-edge-integration.php new file mode 100644 index 0000000..eeb9312 --- /dev/null +++ b/examples/http-edge-integration.php @@ -0,0 +1,165 @@ + */ + private array $connectionCounts = []; + + /** @var array */ + private array $lastActivity = []; + + /** @var int */ + private int $totalRequests = 0; + + /** @var int */ + private int $totalErrors = 0; + + public function resolve(string $resourceId): Result + { + $this->totalRequests++; + + echo "[Resolver] Resolving backend for: {$resourceId}\n"; + + // Validate domain format + if (!preg_match('/^[a-z0-9-]+\.appwrite\.network$/', $resourceId)) { + $this->totalErrors++; + throw new Exception( + "Invalid hostname format: {$resourceId}", + Exception::FORBIDDEN + ); + } + + // Example resolution strategies: + + // Option 1: Kubernetes service discovery (recommended for Edge) + // Extract runtime info and return K8s service + if (preg_match('/^func-([a-z0-9]+)\.appwrite\.network$/', $resourceId, $matches)) { + $functionId = $matches[1]; + $endpoint = "runtime-{$functionId}.runtimes.svc.cluster.local:8080"; + + echo "[Resolver] Resolved to K8s service: {$endpoint}\n"; + + return new Result( + endpoint: $endpoint, + metadata: [ + 'type' => 'function', + 'function_id' => $functionId, + ] + ); + } + + // Option 2: Query database (traditional approach) + // $doc = $db->findOne('functions', [Query::equal('hostname', [$resourceId])]); + // return new Result(endpoint: $doc->getAttribute('endpoint')); + + // Option 3: Query external API (Cloud Platform API) + // $runtime = $edgeApi->getRuntime($resourceId); + // return new Result(endpoint: $runtime['endpoint']); + + // Option 4: Redis cache + fallback + // $endpoint = $redis->get("endpoint:{$resourceId}"); + // if (!$endpoint) { + // $endpoint = $api->resolve($resourceId); + // $redis->setex("endpoint:{$resourceId}", 60, $endpoint); + // } + // return new Result(endpoint: $endpoint); + + $this->totalErrors++; + throw new Exception( + "No backend found for hostname: {$resourceId}", + Exception::NOT_FOUND + ); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connectionCounts[$resourceId] = ($this->connectionCounts[$resourceId] ?? 0) + 1; + $this->lastActivity[$resourceId] = microtime(true); + + echo "[Resolver] Connection opened for: {$resourceId} (active: {$this->connectionCounts[$resourceId]})\n"; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + if (isset($this->connectionCounts[$resourceId])) { + $this->connectionCounts[$resourceId]--; + } + + echo "[Resolver] Connection closed for: {$resourceId}\n"; + + // Example: Log to telemetry, update metrics + } + + public function track(string $resourceId, array $metadata = []): void + { + $this->lastActivity[$resourceId] = microtime(true); + + // Example: Update activity metrics for cold-start detection + } + + public function purge(string $resourceId): void + { + echo "[Resolver] Cache invalidated for: {$resourceId}\n"; + + // Example: Clear Redis cache, notify other workers + } + + public function getStats(): array + { + return [ + 'resolver' => 'edge', + 'total_requests' => $this->totalRequests, + 'total_errors' => $this->totalErrors, + 'active_connections' => array_sum($this->connectionCounts), + 'connections_by_host' => $this->connectionCounts, + ]; + } +}; + +// Create server with custom resolver +$server = new HTTPServer( + $resolver, + host: '0.0.0.0', + port: 8080, + workers: swoole_cpu_num() * 2 +); + +echo "Edge-integrated HTTP Proxy Server\n"; +echo "==================================\n"; +echo "Listening on: http://0.0.0.0:8080\n"; +echo "\nResolver features:\n"; +echo "- resolve: K8s service discovery with domain validation\n"; +echo "- onConnect/onDisconnect: Connection lifecycle tracking\n"; +echo "- track: Activity metrics for cold-start detection\n"; +echo "- getStats: Statistics and telemetry\n\n"; + +$server->start(); diff --git a/examples/http-proxy.php b/examples/http-proxy.php index 013a470..b648ac4 100644 --- a/examples/http-proxy.php +++ b/examples/http-proxy.php @@ -1,62 +1,103 @@ '0.0.0.0', - 'port' => 8080, - 'workers' => swoole_cpu_num() * 2, - - // Performance tuning - 'max_connections' => 100000, - 'max_coroutine' => 100000, - - // Cold-start settings - 'cold_start_timeout' => 30000, // 30 seconds - 'health_check_interval' => 100, // 100ms - - // Backend services - 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', - 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', - - // Database connection - 'db_host' => getenv('DB_HOST') ?: 'localhost', - 'db_port' => (int)(getenv('DB_PORT') ?: 3306), - 'db_user' => getenv('DB_USER') ?: 'appwrite', - 'db_pass' => getenv('DB_PASS') ?: 'password', - 'db_name' => getenv('DB_NAME') ?: 'appwrite', - - // Redis cache - 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', - 'redis_port' => (int)(getenv('REDIS_PORT') ?: 6379), +require __DIR__.'/../vendor/autoload.php'; + +use Utopia\Proxy\Resolver; +use Utopia\Proxy\Resolver\Exception; +use Utopia\Proxy\Resolver\Result; +use Utopia\Proxy\Server\HTTP\Swoole as HTTPServer; + +// Simple static mapping of hostnames to backends +$backends = [ + 'api.example.com' => 'localhost:3000', + 'app.example.com' => 'localhost:3001', + 'admin.example.com' => 'localhost:3002', ]; -echo "Starting HTTP Proxy Server...\n"; -echo "Host: {$config['host']}:{$config['port']}\n"; -echo "Workers: {$config['workers']}\n"; -echo "Max connections: {$config['max_connections']}\n"; -echo "\n"; +// Create resolver with static backend mapping +$resolver = new class ($backends) implements Resolver { + /** @var array */ + private array $backends; + + /** @var array */ + private array $connectionCounts = []; + + public function __construct(array $backends) + { + $this->backends = $backends; + } + + public function resolve(string $resourceId): Result + { + if (!isset($this->backends[$resourceId])) { + throw new Exception( + "No backend configured for hostname: {$resourceId}", + Exception::NOT_FOUND + ); + } -$server = new HttpServer( - host: $config['host'], - port: $config['port'], - workers: $config['workers'], - config: $config + return new Result(endpoint: $this->backends[$resourceId]); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connectionCounts[$resourceId] = ($this->connectionCounts[$resourceId] ?? 0) + 1; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + if (isset($this->connectionCounts[$resourceId])) { + $this->connectionCounts[$resourceId]--; + } + } + + public function track(string $resourceId, array $metadata = []): void + { + // Track activity for cold-start detection + } + + public function purge(string $resourceId): void + { + // No caching in this simple example + } + + public function getStats(): array + { + return [ + 'resolver' => 'static', + 'backends' => count($this->backends), + 'connections' => $this->connectionCounts, + ]; + } +}; + +// Create server +$server = new HTTPServer( + $resolver, + host: '0.0.0.0', + port: 8080, + workers: swoole_cpu_num() * 2 ); +echo "HTTP Proxy Server\n"; +echo "=================\n"; +echo "Listening on: http://0.0.0.0:8080\n"; +echo "\nConfigured backends:\n"; +foreach ($backends as $hostname => $endpoint) { + echo " {$hostname} -> {$endpoint}\n"; +} +echo "\n"; + $server->start(); diff --git a/examples/smtp-proxy.php b/examples/smtp-proxy.php deleted file mode 100644 index e71b21b..0000000 --- a/examples/smtp-proxy.php +++ /dev/null @@ -1,71 +0,0 @@ - - * RCPT TO: - * DATA - * Subject: Test - * - * Hello World - * . - * QUIT - */ - -$config = [ - // Server settings - 'host' => '0.0.0.0', - 'port' => 25, - 'workers' => swoole_cpu_num() * 2, - - // Performance tuning - 'max_connections' => 50000, - 'max_coroutine' => 50000, - - // Cold-start settings - 'cold_start_timeout' => 30000, - 'health_check_interval' => 100, - - // Backend services - 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', - 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', - - // Database connection - 'db_host' => getenv('DB_HOST') ?: 'localhost', - 'db_port' => (int)(getenv('DB_PORT') ?: 3306), - 'db_user' => getenv('DB_USER') ?: 'appwrite', - 'db_pass' => getenv('DB_PASS') ?: 'password', - 'db_name' => getenv('DB_NAME') ?: 'appwrite', - - // Redis cache - 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', - 'redis_port' => (int)(getenv('REDIS_PORT') ?: 6379), -]; - -echo "Starting SMTP Proxy Server...\n"; -echo "Host: {$config['host']}:{$config['port']}\n"; -echo "Workers: {$config['workers']}\n"; -echo "Max connections: {$config['max_connections']}\n"; -echo "\n"; - -$server = new SmtpServer( - host: $config['host'], - port: $config['port'], - workers: $config['workers'], - config: $config -); - -$server->start(); diff --git a/examples/tcp-proxy.php b/examples/tcp-proxy.php deleted file mode 100644 index 0c1d324..0000000 --- a/examples/tcp-proxy.php +++ /dev/null @@ -1,68 +0,0 @@ - '0.0.0.0', - 'workers' => swoole_cpu_num() * 2, - - // Performance tuning - 'max_connections' => 100000, - 'max_coroutine' => 100000, - 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB for database traffic - - // Cold-start settings - 'cold_start_timeout' => 30000, - 'health_check_interval' => 100, - - // Backend services - 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', - 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', - - // Database connection - 'db_host' => getenv('DB_HOST') ?: 'localhost', - 'db_port' => (int)(getenv('DB_PORT') ?: 3306), - 'db_user' => getenv('DB_USER') ?: 'appwrite', - 'db_pass' => getenv('DB_PASS') ?: 'password', - 'db_name' => getenv('DB_NAME') ?: 'appwrite', - - // Redis cache - 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', - 'redis_port' => (int)(getenv('REDIS_PORT') ?: 6379), -]; - -$ports = [5432, 3306]; // PostgreSQL, MySQL - -echo "Starting TCP Proxy Server...\n"; -echo "Host: {$config['host']}\n"; -echo "Ports: " . implode(', ', $ports) . "\n"; -echo "Workers: {$config['workers']}\n"; -echo "Max connections: {$config['max_connections']}\n"; -echo "\n"; - -$server = new TcpServer( - host: $config['host'], - ports: $ports, - workers: $config['workers'], - config: $config -); - -$server->start(); diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..090a56f --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,12 @@ + + + + + tests + tests/Integration + + + tests/Integration + + + diff --git a/pint.json b/pint.json new file mode 100644 index 0000000..14614b0 --- /dev/null +++ b/pint.json @@ -0,0 +1,21 @@ +{ + "preset": "psr12", + "exclude": [ + "./app/sdks", + "./tests/resources/functions", + "./app/console" + ], + "rules": { + "array_indentation": true, + "single_import_per_statement": true, + "simplified_null_return": true, + "ordered_imports": { + "sort_algorithm": "alpha", + "imports_order": [ + "const", + "class", + "function" + ] + } + } +} diff --git a/proxies/http.php b/proxies/http.php new file mode 100644 index 0000000..1bbaa3d --- /dev/null +++ b/proxies/http.php @@ -0,0 +1,186 @@ +endpoint); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + } + + public function track(string $resourceId, array $metadata = []): void + { + } + + public function purge(string $resourceId): void + { + } + + public function getStats(): array + { + return ['resolver' => 'static', 'endpoint' => $this->endpoint]; + } +}; + +$config = [ + // Server settings + 'host' => '0.0.0.0', + 'port' => 8080, + 'workers' => $workers, + 'server_mode' => $serverModeValue, + 'reactor_num' => (int) (getenv('HTTP_REACTOR_NUM') ?: (swoole_cpu_num() * 2)), + 'dispatch_mode' => (int) (getenv('HTTP_DISPATCH_MODE') ?: 2), + + // Performance tuning + 'max_connections' => 100_000, + 'max_coroutine' => 100_000, + 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB + 'buffer_output_size' => 8 * 1024 * 1024, // 8MB + 'log_level' => SWOOLE_LOG_ERROR, + 'backend_pool_size' => $backendPoolSize, + 'backend_pool_timeout' => 0.001, + 'telemetry_headers' => false, + 'fast_path' => $fastPath, + 'fast_path_assume_ok' => $fastAssumeOk, + 'fixed_backend' => $fixedBackend, + 'direct_response' => $directResponse, + 'direct_response_status' => $directResponseStatus, + 'raw_backend' => $rawBackend, + 'raw_backend_assume_ok' => $rawBackendAssumeOk, + 'http_keepalive_timeout' => $httpKeepaliveTimeout, + 'open_http2_protocol' => $openHttp2, + + // Cold-start settings + 'cold_start_timeout' => 30_000, // 30 seconds + 'health_check_interval' => 100, // 100ms + + // Backend services + 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', + 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', + + // Database connection + 'db_host' => getenv('DB_HOST') ?: 'localhost', + 'db_port' => (int) (getenv('DB_PORT') ?: 3306), + 'db_user' => getenv('DB_USER') ?: 'appwrite', + 'db_pass' => getenv('DB_PASS') ?: 'password', + 'db_name' => getenv('DB_NAME') ?: 'appwrite', + + // Redis cache + 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', + 'redis_port' => (int) (getenv('REDIS_PORT') ?: 6379), + + // Skip SSRF validation for trusted backends (e.g., Docker internal networks) + 'skip_validation' => $skipValidation, +]; + +echo "Starting HTTP Proxy Server...\n"; +echo "Host: {$config['host']}:{$config['port']}\n"; +echo "Workers: {$config['workers']}\n"; +echo "Max connections: {$config['max_connections']}\n"; +echo "Server impl: {$serverImpl}\n"; +echo "\n"; + +$serverClass = $serverImpl === 'swoole' ? HTTPServer::class : HTTPCoroutineServer::class; +$server = new $serverClass( + $resolver, + $config['host'], + $config['port'], + $config['workers'], + $config +); + +$server->start(); diff --git a/proxies/smtp.php b/proxies/smtp.php new file mode 100644 index 0000000..ff0a88e --- /dev/null +++ b/proxies/smtp.php @@ -0,0 +1,115 @@ + + * RCPT TO: + * DATA + * Subject: Test + * + * Hello World + * . + * QUIT + */ +$backendEndpoint = getenv('SMTP_BACKEND_ENDPOINT') ?: 'smtp-backend:1025'; +$skipValidation = filter_var(getenv('SMTP_SKIP_VALIDATION') ?: 'false', FILTER_VALIDATE_BOOLEAN); + +// Create a simple resolver that returns the configured backend endpoint +$resolver = new class ($backendEndpoint) implements Resolver { + public function __construct(private string $endpoint) + { + } + + public function resolve(string $resourceId): Result + { + return new Result(endpoint: $this->endpoint); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + } + + public function track(string $resourceId, array $metadata = []): void + { + } + + public function purge(string $resourceId): void + { + } + + public function getStats(): array + { + return ['resolver' => 'static', 'endpoint' => $this->endpoint]; + } +}; + +$config = [ + // Server settings + 'host' => '0.0.0.0', + 'port' => 25, + 'workers' => swoole_cpu_num() * 2, + + // Performance tuning + 'max_connections' => 100000, + 'max_coroutine' => 100000, + 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB + 'buffer_output_size' => 8 * 1024 * 1024, // 8MB + 'log_level' => SWOOLE_LOG_ERROR, + + // Cold-start settings + 'cold_start_timeout' => 30000, + 'health_check_interval' => 100, + + // Backend services + 'compute_api_url' => getenv('COMPUTE_API_URL') ?: 'http://appwrite-api/v1/compute', + 'compute_api_key' => getenv('COMPUTE_API_KEY') ?: '', + + // Database connection + 'db_host' => getenv('DB_HOST') ?: 'localhost', + 'db_port' => (int) (getenv('DB_PORT') ?: 3306), + 'db_user' => getenv('DB_USER') ?: 'appwrite', + 'db_pass' => getenv('DB_PASS') ?: 'password', + 'db_name' => getenv('DB_NAME') ?: 'appwrite', + + // Redis cache + 'redis_host' => getenv('REDIS_HOST') ?: '127.0.0.1', + 'redis_port' => (int) (getenv('REDIS_PORT') ?: 6379), + + // Skip SSRF validation for trusted backends (e.g., Docker internal networks) + 'skip_validation' => $skipValidation, +]; + +echo "Starting SMTP Proxy Server...\n"; +echo "Host: {$config['host']}:{$config['port']}\n"; +echo "Workers: {$config['workers']}\n"; +echo "Max connections: {$config['max_connections']}\n"; +echo "\n"; + +$server = new SMTPServer( + $resolver, + $config['host'], + $config['port'], + $config['workers'], + $config +); + +$server->start(); diff --git a/proxies/tcp.php b/proxies/tcp.php new file mode 100644 index 0000000..da1d5b4 --- /dev/null +++ b/proxies/tcp.php @@ -0,0 +1,149 @@ +endpoint); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + } + + public function track(string $resourceId, array $metadata = []): void + { + } + + public function purge(string $resourceId): void + { + } + + public function getStats(): array + { + return ['resolver' => 'static', 'endpoint' => $this->endpoint]; + } +}; + +$postgresPort = $envInt('TCP_POSTGRES_PORT', 5432); +$mysqlPort = $envInt('TCP_MYSQL_PORT', 3306); +$ports = array_values(array_filter([$postgresPort, $mysqlPort], static fn (int $port): bool => $port > 0)); +if ($ports === []) { + $ports = [5432, 3306]; +} + +$config = new TCPConfig( + host: '0.0.0.0', + ports: $ports, + workers: $workers, + reactorNum: $reactorNum, + dispatchMode: $dispatchMode, + skipValidation: $skipValidation, + tls: $tls, +); + +echo "Starting TCP Proxy Server...\n"; +echo "Host: {$config->host}\n"; +echo 'Ports: '.implode(', ', $config->ports)."\n"; +echo "Workers: {$config->workers}\n"; +echo "Max connections: {$config->maxConnections}\n"; +echo "Server impl: {$serverImpl}\n"; +if ($tls !== null) { + echo "TLS: enabled (cert: {$tls->certPath})\n"; + if ($tls->isMutualTLS()) { + echo "mTLS: enabled (ca: {$tls->caPath})\n"; + } +} +echo "\n"; + +$serverClass = $serverImpl === 'swoole' ? TCPServer::class : TCPCoroutineServer::class; +$server = new $serverClass($resolver, $config); + +$server->start(); diff --git a/src/Adapter.php b/src/Adapter.php new file mode 100644 index 0000000..b548d72 --- /dev/null +++ b/src/Adapter.php @@ -0,0 +1,313 @@ + Connection pool stats */ + protected array $stats = [ + 'connections' => 0, + 'cacheHits' => 0, + 'cacheMisses' => 0, + 'routingErrors' => 0, + ]; + + /** @var bool Skip SSRF validation for trusted backends */ + protected bool $skipValidation = false; + + /** @var int Activity tracking interval in seconds */ + protected int $activityInterval = 30; + + /** @var array Last activity timestamp per resource */ + protected array $lastActivityUpdate = []; + + /** @var array Byte counters per resource since last flush */ + protected array $byteCounters = []; + + public function __construct( + public Resolver $resolver { + get { + return $this->resolver; + } + }, + protected string $name = 'Generic', + protected Protocol $protocol = Protocol::TCP, + protected string $description = 'Generic proxy adapter', + ) { + $this->initRouter(); + } + + /** + * Set activity tracking interval + */ + public function setActivityInterval(int $seconds): static + { + $this->activityInterval = $seconds; + + return $this; + } + + /** + * Skip SSRF validation for trusted backends + */ + public function setSkipValidation(bool $skip): static + { + $this->skipValidation = $skip; + + return $this; + } + + /** + * Notify connect event + * + * @param array $metadata Additional connection metadata + */ + public function notifyConnect(string $resourceId, array $metadata = []): void + { + $this->resolver->onConnect($resourceId, $metadata); + } + + /** + * Notify close event + * + * @param array $metadata Additional disconnection metadata + */ + public function notifyClose(string $resourceId, array $metadata = []): void + { + // Flush remaining bytes on disconnect + if (isset($this->byteCounters[$resourceId])) { + $metadata['inboundBytes'] = $this->byteCounters[$resourceId]['inbound']; + $metadata['outboundBytes'] = $this->byteCounters[$resourceId]['outbound']; + unset($this->byteCounters[$resourceId]); + } + + $this->resolver->onDisconnect($resourceId, $metadata); + unset($this->lastActivityUpdate[$resourceId]); + } + + /** + * Record bytes transferred for a resource + */ + public function recordBytes( + string $resourceId, + int $inbound = 0, + int $outbound = 0, + ): void { + if (!isset($this->byteCounters[$resourceId])) { + $this->byteCounters[$resourceId] = ['inbound' => 0, 'outbound' => 0]; + } + + $this->byteCounters[$resourceId]['inbound'] += $inbound; + $this->byteCounters[$resourceId]['outbound'] += $outbound; + } + + /** + * @param array $metadata Activity metadata + */ + public function track(string $resourceId, array $metadata = []): void + { + $now = time(); + $lastUpdate = $this->lastActivityUpdate[$resourceId] ?? 0; + + if (($now - $lastUpdate) < $this->activityInterval) { + return; + } + + $this->lastActivityUpdate[$resourceId] = $now; + + // Flush accumulated byte counters into the activity metadata + if (isset($this->byteCounters[$resourceId])) { + $metadata['inboundBytes'] = $this->byteCounters[$resourceId]['inbound']; + $metadata['outboundBytes'] = $this->byteCounters[$resourceId]['outbound']; + $this->byteCounters[$resourceId] = ['inbound' => 0, 'outbound' => 0]; + } + + $this->resolver->track($resourceId, $metadata); + } + + /** + * Get adapter name + */ + public function getName(): string + { + return $this->name; + } + + /** + * Get protocol type + */ + public function getProtocol(): Protocol + { + return $this->protocol; + } + + /** + * Get adapter description + */ + public function getDescription(): string + { + return $this->description; + } + + /** + * Route connection to backend + * + * @param string $resourceId Protocol-specific identifier + * @return ConnectionResult Backend endpoint and metadata + * + * @throws ResolverException If routing fails + */ + public function route(string $resourceId): ConnectionResult + { + $cached = $this->router->get($resourceId); + $now = \time(); + + if ($cached !== false && \is_array($cached)) { + /** @var array{endpoint: string, updated: int} $cached */ + if (($now - $cached['updated']) < 1) { + $this->stats['cacheHits']++; + $this->stats['connections']++; + + return new ConnectionResult( + endpoint: $cached['endpoint'], + protocol: $this->getProtocol(), + metadata: ['cached' => true] + ); + } + } + + $this->stats['cacheMisses']++; + + try { + $result = $this->resolver->resolve($resourceId); + $endpoint = $result->endpoint; + + if (empty($endpoint)) { + throw new ResolverException( + "Resolver returned empty endpoint for: {$resourceId}", + ResolverException::NOT_FOUND + ); + } + + if (! $this->skipValidation) { + $this->validateEndpoint($endpoint); + } + + $this->router->set($resourceId, [ + 'endpoint' => $endpoint, + 'updated' => $now, + ]); + + $this->stats['connections']++; + + return new ConnectionResult( + endpoint: $endpoint, + protocol: $this->getProtocol(), + metadata: \array_merge(['cached' => false], $result->metadata) + ); + } catch (\Exception $e) { + $this->stats['routingErrors']++; + throw $e; + } + } + + /** + * Validate backend endpoint to prevent SSRF attacks + */ + protected function validateEndpoint(string $endpoint): void + { + $parts = \explode(':', $endpoint); + if (\count($parts) > 2) { + throw new ResolverException("Invalid endpoint format: {$endpoint}"); + } + + $host = $parts[0]; + $port = isset($parts[1]) ? (int) $parts[1] : 0; + + if ($port > 65535) { + throw new ResolverException("Invalid port number: {$port}"); + } + + $ip = \gethostbyname($host); + if ($ip === $host && ! \filter_var($ip, FILTER_VALIDATE_IP)) { + throw new ResolverException("Cannot resolve hostname: {$host}"); + } + + if (\filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)) { + $longIp = \ip2long($ip); + if ($longIp === false) { + throw new ResolverException("Invalid IP address: {$ip}"); + } + + $blockedRanges = [ + ['10.0.0.0', '10.255.255.255'], + ['172.16.0.0', '172.31.255.255'], + ['192.168.0.0', '192.168.255.255'], + ['127.0.0.0', '127.255.255.255'], + ['169.254.0.0', '169.254.255.255'], + ['224.0.0.0', '239.255.255.255'], + ['240.0.0.0', '255.255.255.255'], + ['0.0.0.0', '0.255.255.255'], + ]; + + foreach ($blockedRanges as [$rangeStart, $rangeEnd]) { + $rangeStartLong = \ip2long($rangeStart); + $rangeEndLong = \ip2long($rangeEnd); + if ($longIp >= $rangeStartLong && $longIp <= $rangeEndLong) { + throw new ResolverException("Access to private/reserved IP address is forbidden: {$ip}"); + } + } + } elseif (\filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)) { + if ($ip === '::1' || \str_starts_with($ip, 'fe80:') || \str_starts_with($ip, 'fc00:') || \str_starts_with($ip, 'fd00:')) { + throw new ResolverException("Access to private/reserved IPv6 address is forbidden: {$ip}"); + } + } + } + + /** + * Initialize routing cache table + */ + protected function initRouter(): void + { + $this->router = new Table(200_000); + $this->router->column('endpoint', Table::TYPE_STRING, 256); + $this->router->column('updated', Table::TYPE_INT, 8); + $this->router->create(); + } + + /** + * Get routing and connection stats + * + * @return array + */ + public function getStats(): array + { + $totalRequests = $this->stats['cacheHits'] + $this->stats['cacheMisses']; + + return [ + 'adapter' => $this->getName(), + 'protocol' => $this->getProtocol()->value, + 'connections' => $this->stats['connections'], + 'cacheHits' => $this->stats['cacheHits'], + 'cacheMisses' => $this->stats['cacheMisses'], + 'cacheHitRate' => $totalRequests > 0 + ? \round($this->stats['cacheHits'] / $totalRequests * 100, 2) + : 0, + 'routingErrors' => $this->stats['routingErrors'], + 'routingTableMemory' => $this->router->memorySize, + 'routingTableSize' => $this->router->count(), + 'resolver' => $this->resolver->getStats(), + ]; + } +} diff --git a/src/Adapter/TCP.php b/src/Adapter/TCP.php new file mode 100644 index 0000000..c9f62f6 --- /dev/null +++ b/src/Adapter/TCP.php @@ -0,0 +1,571 @@ +setReadWriteSplit(true); // Enable read/write routing + * ``` + */ +class TCP extends Adapter +{ + /** @var array */ + protected array $backendConnections = []; + + /** @var float Backend connection timeout in seconds */ + protected float $connectTimeout = 5.0; + + /** @var bool Whether read/write split routing is enabled */ + protected bool $readWriteSplit = false; + + /** @var Parser|null Lazy-initialized query parser */ + protected ?Parser $queryParser = null; + + /** + * Per-connection transaction pinning state. + * When a connection is in a transaction, all queries are routed to primary. + * + * @var array + */ + protected array $pinnedConnections = []; + + public function __construct( + Resolver $resolver, + public int $port { + get { + return $this->port; + } + } + ) { + parent::__construct($resolver); + } + + /** + * Set backend connection timeout + */ + public function setConnectTimeout(float $timeout): static + { + $this->connectTimeout = $timeout; + + return $this; + } + + /** + * Enable or disable read/write split routing + * + * When enabled, the adapter inspects each data packet to classify queries + * and route reads to replicas and writes to the primary. + * Requires the resolver to implement ReadWriteResolver for full functionality. + * Falls back to normal resolve() if the resolver does not implement it. + */ + public function setReadWriteSplit(bool $enabled): static + { + $this->readWriteSplit = $enabled; + + return $this; + } + + /** + * Check if read/write split is enabled + */ + public function isReadWriteSplit(): bool + { + return $this->readWriteSplit; + } + + /** + * Check if a connection is pinned to primary (in a transaction) + */ + public function isConnectionPinned(int $clientFd): bool + { + return $this->pinnedConnections[$clientFd] ?? false; + } + + /** + * Get adapter name + */ + public function getName(): string + { + return 'TCP'; + } + + /** + * Get protocol type + */ + public function getProtocol(): Protocol + { + return match ($this->port) { + 5432 => Protocol::PostgreSQL, + 27017 => Protocol::MongoDB, + 3306 => Protocol::MySQL, + default => throw new \Exception('Unsupported protocol on port: ' . $this->port), + }; + } + + /** + * Get adapter description + */ + public function getDescription(): string + { + return 'TCP proxy adapter for database connections (PostgreSQL, MySQL, MongoDB)'; + } + + /** + * Parse database ID from TCP packet + * + * For PostgreSQL: Extract from SNI or startup message + * For MySQL: Extract from initial handshake + * + * @throws \Exception + */ + public function parseDatabaseId(string $data, int $fd): string + { + return match ($this->getProtocol()) { + Protocol::PostgreSQL => $this->parsePostgreSQLDatabaseId($data), + Protocol::MongoDB => $this->parseMongoDatabaseId($data), + Protocol::MySQL => $this->parseMySQLDatabaseId($data), + default => throw new \Exception('Unsupported protocol: ' . $this->getProtocol()->value), + }; + } + + /** + * Classify a data packet for read/write routing + * + * Determines whether a query packet should be routed to a read replica + * or the primary writer. Handles transaction pinning automatically. + * + * @param string $data Raw protocol data packet + * @param int $clientFd Client file descriptor for transaction tracking + * @return QueryType QueryType::Read or QueryType::Write + */ + public function classifyQuery(string $data, int $clientFd): QueryType + { + if (!$this->readWriteSplit) { + return QueryType::Write; + } + + // If connection is pinned to primary (in transaction), everything goes to primary + if ($this->isConnectionPinned($clientFd)) { + $classification = $this->getQueryParser()->parse($data); + + // Transaction end unpins + if ($classification === QueryType::TransactionEnd) { + unset($this->pinnedConnections[$clientFd]); + } + + return QueryType::Write; + } + + $classification = $this->getQueryParser()->parse($data); + + // Transaction begin pins to primary + if ($classification === QueryType::TransactionBegin) { + $this->pinnedConnections[$clientFd] = true; + + return QueryType::Write; + } + + // Other transaction commands and unknown go to primary for safety + if ($classification === QueryType::Transaction + || $classification === QueryType::TransactionEnd + || $classification === QueryType::Unknown + ) { + return QueryType::Write; + } + + return $classification; + } + + /** + * Route a query to the appropriate backend (read replica or primary) + * + * @param string $resourceId Database/resource identifier + * @param QueryType $queryType QueryType::Read or QueryType::Write + * @return ConnectionResult Resolved backend endpoint + * + * @throws ResolverException + */ + public function routeQuery(string $resourceId, QueryType $queryType): ConnectionResult + { + // If read/write split is disabled or resolver doesn't support it, use default routing + if (!$this->readWriteSplit || !($this->resolver instanceof ReadWriteResolver)) { + return $this->route($resourceId); + } + + if ($queryType === QueryType::Read) { + return $this->routeRead($resourceId); + } + + return $this->routeWrite($resourceId); + } + + /** + * Clear transaction pinning state for a connection + * + * Should be called when a client disconnects to clean up state. + */ + public function clearConnectionState(int $clientFd): void + { + unset($this->pinnedConnections[$clientFd]); + } + + /** + * Parse PostgreSQL database ID from startup message + * + * Format: "database\0db-abc123\0" + * + * @throws \Exception + */ + protected function parsePostgreSQLDatabaseId(string $data): string + { + // Fast path: find "database\0" marker + $marker = "database\x00"; + $pos = \strpos($data, $marker); + if ($pos === false) { + throw new \Exception('Invalid PostgreSQL database name'); + } + + // Extract database name until next null byte + $start = $pos + 9; // strlen("database\0") + $end = strpos($data, "\x00", $start); + if ($end === false) { + throw new \Exception('Invalid PostgreSQL database name'); + } + + $dbName = substr($data, $start, $end - $start); + + // Must start with "db-" + if (strncmp($dbName, 'db-', 3) !== 0) { + throw new \Exception('Invalid PostgreSQL database name'); + } + + // Extract ID (alphanumeric after "db-", stop at dot or end) + $idStart = 3; + $len = \strlen($dbName); + $idEnd = $idStart; + + while ($idEnd < $len) { + $c = $dbName[$idEnd]; + if ($c === '.') { + break; + } + // Allow a-z, A-Z, 0-9 + if (($c >= 'a' && $c <= 'z') || ($c >= 'A' && $c <= 'Z') || ($c >= '0' && $c <= '9')) { + $idEnd++; + } else { + throw new \Exception('Invalid PostgreSQL database name'); + } + } + + if ($idEnd === $idStart) { + throw new \Exception('Invalid PostgreSQL database name'); + } + + return \substr($dbName, $idStart, $idEnd - $idStart); + } + + /** + * Parse MySQL database ID from connection + * + * For MySQL, we typically get the database from subsequent COM_INIT_DB packet + * + * @throws \Exception + */ + protected function parseMySQLDatabaseId(string $data): string + { + // MySQL COM_INIT_DB packet (0x02) + $len = strlen($data); + if ($len <= 5 || \ord($data[4]) !== 0x02) { + throw new \Exception('Invalid MySQL database name'); + } + + // Extract database name, removing null terminator + $dbName = \substr($data, 5); + $nullPos = \strpos($dbName, "\x00"); + if ($nullPos !== false) { + $dbName = \substr($dbName, 0, $nullPos); + } + + // Must start with "db-" + if (\strncmp($dbName, 'db-', 3) !== 0) { + throw new \Exception('Invalid MySQL database name'); + } + + // Extract ID (alphanumeric after "db-", stop at dot or end) + $idStart = 3; + $nameLen = \strlen($dbName); + $idEnd = $idStart; + + while ($idEnd < $nameLen) { + $c = $dbName[$idEnd]; + if ($c === '.') { + break; + } + // Allow a-z, A-Z, 0-9 + if (($c >= 'a' && $c <= 'z') || ($c >= 'A' && $c <= 'Z') || ($c >= '0' && $c <= '9')) { + $idEnd++; + } else { + throw new \Exception('Invalid MySQL database name'); + } + } + + if ($idEnd === $idStart) { + throw new \Exception('Invalid MySQL database name'); + } + + return \substr($dbName, $idStart, $idEnd - $idStart); + } + + /** + * Parse MongoDB database ID from OP_MSG + * + * MongoDB OP_MSG contains a BSON document with a "$db" field holding the database name. + * We search for the "$db\0" marker and extract the following BSON string value. + * + * @throws \Exception + */ + protected function parseMongoDatabaseId(string $data): string + { + // MongoDB OP_MSG: header (16 bytes) + flagBits (4 bytes) + section kind (1 byte) + BSON document + // The BSON document contains a "$db" field with the database name + // Look for the "$db\0" marker in the data + $marker = "\$db\0"; + $pos = \strpos($data, $marker); + + if ($pos === false) { + throw new \Exception('Invalid MongoDB database name'); + } + + // After "$db\0" comes the BSON type byte (0x02 = string), then: + // 4 bytes little-endian string length, then the null-terminated string + $offset = $pos + \strlen($marker); + + if ($offset + 4 >= \strlen($data)) { + throw new \Exception('Invalid MongoDB database name'); + } + + $unpacked = \unpack('V', \substr($data, $offset, 4)); + if ($unpacked === false) { + throw new \Exception('Invalid MongoDB database name'); + } + /** @var int $strLen */ + $strLen = $unpacked[1]; + $offset += 4; + + if ($offset + $strLen > \strlen($data)) { + throw new \Exception('Invalid MongoDB database name'); + } + + $dbName = \substr($data, $offset, $strLen - 1); // -1 for null terminator + + if (\strncmp($dbName, 'db-', 3) !== 0) { + throw new \Exception('Invalid MongoDB database name'); + } + + // Extract ID (alphanumeric after "db-", stop at dot or end) + $idStart = 3; + $nameLen = \strlen($dbName); + $idEnd = $idStart; + + while ($idEnd < $nameLen) { + $c = $dbName[$idEnd]; + if ($c === '.') { + break; + } + // Allow a-z, A-Z, 0-9 + if (($c >= 'a' && $c <= 'z') || ($c >= 'A' && $c <= 'Z') || ($c >= '0' && $c <= '9')) { + $idEnd++; + } else { + throw new \Exception('Invalid MongoDB database name'); + } + } + + if ($idEnd === $idStart) { + throw new \Exception('Invalid MongoDB database name'); + } + + return \substr($dbName, $idStart, $idEnd - $idStart); + } + + /** + * Get or create backend connection + * + * Performance: Reuses connections for same database + * + * @throws \Exception + */ + public function getBackendConnection(string $databaseId, int $clientFd): Client + { + // Check if we already have a connection for this database + $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; + + if (isset($this->backendConnections[$cacheKey])) { + return $this->backendConnections[$cacheKey]; + } + + // Get backend endpoint via routing + $result = $this->route($databaseId); + + // Create new TCP connection to backend + [$host, $port] = \explode(':', $result->endpoint.':'.$this->port); + $port = (int) $port; + + $client = new Client(SWOOLE_SOCK_TCP); + + // Optimize socket for low latency + $client->set([ + 'timeout' => $this->connectTimeout, + 'connect_timeout' => $this->connectTimeout, + 'open_tcp_nodelay' => true, // Disable Nagle's algorithm + 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB buffer + ]); + + if (!$client->connect($host, $port, $this->connectTimeout)) { + throw new \Exception("Failed to connect to backend: {$host}:{$port}"); + } + + $this->backendConnections[$cacheKey] = $client; + + return $client; + } + + /** + * Close backend connection + */ + public function closeBackendConnection(string $databaseId, int $clientFd): void + { + $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; + + if (isset($this->backendConnections[$cacheKey])) { + $this->backendConnections[$cacheKey]->close(); + unset($this->backendConnections[$cacheKey]); + } + } + + /** + * Get or create the query parser instance (lazy initialization) + */ + protected function getQueryParser(): Parser + { + if ($this->queryParser === null) { + $this->queryParser = match ($this->getProtocol()) { + Protocol::PostgreSQL => new PostgreSQLParser(), + Protocol::MySQL => new MySQLParser(), + Protocol::MongoDB => new MongoDBParser(), + default => throw new \Exception('No query parser for protocol: ' . $this->getProtocol()->value), + }; + } + + return $this->queryParser; + } + + /** + * Route to a read replica backend + * + * @throws ResolverException + */ + protected function routeRead(string $resourceId): ConnectionResult + { + /** @var ReadWriteResolver $resolver */ + $resolver = $this->resolver; + + try { + $result = $resolver->resolveRead($resourceId); + $endpoint = $result->endpoint; + + if (empty($endpoint)) { + throw new ResolverException( + "Resolver returned empty read endpoint for: {$resourceId}", + ResolverException::NOT_FOUND + ); + } + + if (!$this->skipValidation) { + $this->validateEndpoint($endpoint); + } + + return new ConnectionResult( + endpoint: $endpoint, + protocol: $this->getProtocol(), + metadata: \array_merge(['cached' => false, 'route' => 'read'], $result->metadata) + ); + } catch (\Exception $e) { + $this->stats['routingErrors']++; + throw $e; + } + } + + /** + * Route to the primary/writer backend + * + * @throws ResolverException + */ + protected function routeWrite(string $resourceId): ConnectionResult + { + /** @var ReadWriteResolver $resolver */ + $resolver = $this->resolver; + + try { + $result = $resolver->resolveWrite($resourceId); + $endpoint = $result->endpoint; + + if (empty($endpoint)) { + throw new ResolverException( + "Resolver returned empty write endpoint for: {$resourceId}", + ResolverException::NOT_FOUND + ); + } + + if (!$this->skipValidation) { + $this->validateEndpoint($endpoint); + } + + return new ConnectionResult( + endpoint: $endpoint, + protocol: $this->getProtocol(), + metadata: \array_merge(['cached' => false, 'route' => 'write'], $result->metadata) + ); + } catch (\Exception $e) { + $this->stats['routingErrors']++; + throw $e; + } + } +} diff --git a/src/ConnectionManager.php b/src/ConnectionManager.php deleted file mode 100644 index 91e0d66..0000000 --- a/src/ConnectionManager.php +++ /dev/null @@ -1,262 +0,0 @@ - 0, - 'cold_starts' => 0, - 'cache_hits' => 0, - 'cache_misses' => 0, - ]; - - public function __construct( - Cache $cache, - Group $dbPool, - string $computeApiUrl, - string $computeApiKey, - int $coldStartTimeout = 30_000, - int $healthCheckInterval = 100 - ) { - $this->cache = $cache; - $this->dbPool = $dbPool; - $this->computeApiUrl = $computeApiUrl; - $this->computeApiKey = $computeApiKey; - $this->coldStartTimeout = $coldStartTimeout; - $this->healthCheckInterval = $healthCheckInterval; - - // Initialize shared memory table for ultra-fast lookups - $this->initStatusTable(); - } - - /** - * Initialize Swoole shared memory table - * 100k entries = ~10MB memory, O(1) lookups - */ - protected function initStatusTable(): void - { - $this->statusTable = new \Swoole\Table(100_000); - $this->statusTable->column('status', \Swoole\Table::TYPE_STRING, 16); - $this->statusTable->column('endpoint', \Swoole\Table::TYPE_STRING, 64); - $this->statusTable->column('updated', \Swoole\Table::TYPE_INT, 8); - $this->statusTable->create(); - } - - /** - * Main connection handling flow - FAST AS FUCK - * - * Performance: <1ms for cache hit, <100ms for cold-start - */ - public function handleConnection(string $resourceId): ConnectionResult - { - $startTime = microtime(true); - - // 1. Check shared memory first (fastest path - O(1)) - $cached = $this->statusTable->get($resourceId); - if ($cached && (time() - $cached['updated']) < 1) { - $this->stats['cache_hits']++; - - if ($cached['status'] === ResourceStatus::ACTIVE) { - return new ConnectionResult( - endpoint: $cached['endpoint'], - protocol: $this->getProtocol(), - metadata: ['cached' => true, 'latency_ms' => round((microtime(true) - $startTime) * 1000, 2)] - ); - } - } - - $this->stats['cache_misses']++; - - // 2. Identify target resource (database lookup via connection pool) - $resource = $this->identifyResource($resourceId); - - // 3. Check resource status - $status = $this->getResourceStatus($resource); - - // 4. If inactive, trigger cold-start (async coroutine) - if ($status === ResourceStatus::INACTIVE) { - $this->stats['cold_starts']++; - $this->triggerColdStart($resource); - $this->waitForReady($resource); - } - - // 5. Get connection endpoint - $endpoint = $this->getEndpoint($resource); - - // 6. Update shared memory cache - $this->statusTable->set($resourceId, [ - 'status' => ResourceStatus::ACTIVE, - 'endpoint' => $endpoint, - 'updated' => time(), - ]); - - $this->stats['connections']++; - - return new ConnectionResult( - endpoint: $endpoint, - protocol: $this->getProtocol(), - metadata: [ - 'cached' => false, - 'cold_start' => $status === ResourceStatus::INACTIVE, - 'latency_ms' => round((microtime(true) - $startTime) * 1000, 2) - ] - ); - } - - /** - * Protocol-specific implementations must override these - */ - abstract protected function identifyResource(string $resourceId): Resource; - abstract protected function getProtocol(): string; - - /** - * Get resource status with aggressive caching - * - * Performance: <1ms with cache, <10ms without - */ - protected function getResourceStatus(Resource $resource): string - { - // Check Redis cache first - $cacheKey = "container:status:{$resource->id}"; - $cached = $this->cache->load($cacheKey); - - if ($cached !== null && $cached !== false) { - return $cached; - } - - // Query database via connection pool - $db = $this->dbPool->get(); - try { - $doc = $db->getDocument('containers', $resource->containerId); - $status = $doc->getAttribute('status', ResourceStatus::INACTIVE); - - // Cache for 1 second (balance freshness vs performance) - $this->cache->save($cacheKey, $status, 1); - - return $status; - } finally { - $this->dbPool->put($db); - } - } - - /** - * Trigger cold-start via Compute API (async coroutine) - * - * Performance: Non-blocking, returns immediately - */ - protected function triggerColdStart(Resource $resource): void - { - // Use Swoole HTTP client for async requests - Coroutine::create(function () use ($resource) { - $client = new \Swoole\Coroutine\Http\Client( - parse_url($this->computeApiUrl, PHP_URL_HOST), - parse_url($this->computeApiUrl, PHP_URL_PORT) ?? 80 - ); - - $client->setHeaders([ - 'Authorization' => 'Bearer ' . $this->computeApiKey, - 'Content-Type' => 'application/json', - ]); - - $client->set(['timeout' => 5]); - - $client->post( - "/containers/{$resource->containerId}/start", - json_encode(['resourceId' => $resource->id]) - ); - - $client->close(); - }); - } - - /** - * Wait for container to become ready - * - * Performance: <100ms for warm pool, <30s for cold-start - */ - protected function waitForReady(Resource $resource): void - { - $startTime = microtime(true); - $channel = new Channel(1); - - // Health check in coroutine - Coroutine::create(function () use ($resource, $channel, $startTime) { - while ((microtime(true) - $startTime) * 1000 < $this->coldStartTimeout) { - $status = $this->getResourceStatus($resource); - - if ($status === ResourceStatus::ACTIVE) { - $channel->push(true); - return; - } - - Coroutine::sleep($this->healthCheckInterval / 1000); - } - - $channel->push(false); - }); - - $ready = $channel->pop($this->coldStartTimeout / 1000); - - if (!$ready) { - throw new \Exception("Cold-start timeout after {$this->coldStartTimeout}ms"); - } - } - - /** - * Get connection endpoint from database - * - * Performance: <10ms with connection pooling - */ - protected function getEndpoint(Resource $resource): string - { - $db = $this->dbPool->get(); - try { - $doc = $db->getDocument('containers', $resource->containerId); - return $doc->getAttribute('internalIP'); - } finally { - $this->dbPool->put($db); - } - } - - /** - * Get connection stats for monitoring - */ - public function getStats(): array - { - return array_merge($this->stats, [ - 'cache_hit_rate' => $this->stats['cache_hits'] + $this->stats['cache_misses'] > 0 - ? round($this->stats['cache_hits'] / ($this->stats['cache_hits'] + $this->stats['cache_misses']) * 100, 2) - : 0, - 'status_table_memory' => $this->statusTable->memorySize, - 'status_table_size' => $this->statusTable->count(), - ]); - } -} diff --git a/src/ConnectionResult.php b/src/ConnectionResult.php index 6cf1ff5..449e1ae 100644 --- a/src/ConnectionResult.php +++ b/src/ConnectionResult.php @@ -1,15 +1,19 @@ $metadata + */ public function __construct( - public string $endpoint, - public string $protocol, - public array $metadata = [] - ) {} + public private(set) string $endpoint, + public private(set) Protocol $protocol, + public private(set) array $metadata = [] + ) { + } } diff --git a/src/Http/HttpConnectionManager.php b/src/Http/HttpConnectionManager.php deleted file mode 100644 index 0632bf0..0000000 --- a/src/Http/HttpConnectionManager.php +++ /dev/null @@ -1,46 +0,0 @@ -dbPool->get(); - - try { - $doc = $db->findOne('functions', [ - Query::equal('hostname', [$resourceId]) - ]); - - if (empty($doc)) { - throw new \Exception("Function not found for hostname: {$resourceId}"); - } - - return new Resource( - id: $doc->getId(), - containerId: $doc->getAttribute('containerId'), - type: 'function', - tier: $doc->getAttribute('tier', 'shared'), - region: $doc->getAttribute('region') - ); - } finally { - $this->dbPool->put($db); - } - } - - protected function getProtocol(): string - { - return 'http'; - } -} diff --git a/src/Http/HttpServer.php b/src/Http/HttpServer.php deleted file mode 100644 index 3c4164e..0000000 --- a/src/Http/HttpServer.php +++ /dev/null @@ -1,229 +0,0 @@ -config = array_merge([ - 'host' => $host, - 'port' => $port, - 'workers' => $workers, - 'max_connections' => 100_000, - 'max_coroutine' => 100_000, - 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB - 'buffer_output_size' => 2 * 1024 * 1024, // 2MB - 'enable_coroutine' => true, - 'max_wait_time' => 60, - ], $config); - - $this->server = new Server($host, $port, SWOOLE_PROCESS); - $this->configure(); - } - - protected function configure(): void - { - $this->server->set([ - 'worker_num' => $this->config['workers'], - 'max_connection' => $this->config['max_connections'], - 'max_coroutine' => $this->config['max_coroutine'], - 'socket_buffer_size' => $this->config['socket_buffer_size'], - 'buffer_output_size' => $this->config['buffer_output_size'], - 'enable_coroutine' => $this->config['enable_coroutine'], - 'max_wait_time' => $this->config['max_wait_time'], - - // Performance tuning - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - - // Enable stats - 'task_enable_coroutine' => true, - ]); - - $this->server->on('start', $this->onStart(...)); - $this->server->on('workerStart', $this->onWorkerStart(...)); - $this->server->on('request', $this->onRequest(...)); - } - - public function onStart(Server $server): void - { - echo "HTTP Proxy Server started at http://{$this->config['host']}:{$this->config['port']}\n"; - echo "Workers: {$this->config['workers']}\n"; - echo "Max connections: {$this->config['max_connections']}\n"; - } - - public function onWorkerStart(Server $server, int $workerId): void - { - // Initialize connection manager per worker - $this->manager = new HttpConnectionManager( - cache: $this->initCache(), - dbPool: $this->initDbPool(), - computeApiUrl: $this->config['compute_api_url'] ?? 'http://appwrite-api/v1/compute', - computeApiKey: $this->config['compute_api_key'] ?? '', - coldStartTimeout: $this->config['cold_start_timeout'] ?? 30000, - healthCheckInterval: $this->config['health_check_interval'] ?? 100 - ); - - echo "Worker #{$workerId} started\n"; - } - - /** - * Main request handler - FAST AS FUCK - * - * Performance: <1ms for cache hit - */ - public function onRequest(Request $request, Response $response): void - { - $startTime = microtime(true); - - try { - // Extract hostname from request - $hostname = $request->header['host'] ?? null; - - if (!$hostname) { - $response->status(400); - $response->end('Missing Host header'); - return; - } - - // Handle connection routing - $result = $this->manager->handleConnection($hostname); - - // Forward request to backend (zero-copy where possible) - $this->forwardRequest($request, $response, $result->endpoint); - - // Add telemetry headers - $latency = round((microtime(true) - $startTime) * 1000, 2); - $response->header('X-Proxy-Latency-Ms', (string)$latency); - $response->header('X-Proxy-Protocol', $result->protocol); - - if (isset($result->metadata['cached'])) { - $response->header('X-Proxy-Cache', $result->metadata['cached'] ? 'HIT' : 'MISS'); - } - - } catch (\Exception $e) { - $response->status(503); - $response->header('Content-Type', 'application/json'); - $response->end(json_encode([ - 'error' => 'Service Unavailable', - 'message' => $e->getMessage(), - ])); - } - } - - /** - * Forward HTTP request to backend using Swoole HTTP client - * - * Performance: Zero-copy streaming for large responses - */ - protected function forwardRequest(Request $request, Response $response, string $endpoint): void - { - [$host, $port] = explode(':', $endpoint . ':80'); - $port = (int)$port; - - $client = new \Swoole\Coroutine\Http\Client($host, $port); - - // Set timeout - $client->set([ - 'timeout' => 30, - 'keep_alive' => true, - ]); - - // Forward headers - $headers = []; - foreach ($request->header as $key => $value) { - if (!in_array(strtolower($key), ['host', 'connection'])) { - $headers[$key] = $value; - } - } - $client->setHeaders($headers); - - // Forward cookies - if (!empty($request->cookie)) { - $client->setCookies($request->cookie); - } - - // Forward request body - $body = $request->getContent() ?: ''; - - // Make request - $method = strtolower($request->server['request_method']); - $path = $request->server['request_uri']; - - $client->$method($path, $body); - - // Forward response - $response->status($client->statusCode); - - // Forward response headers - if (!empty($client->headers)) { - foreach ($client->headers as $key => $value) { - $response->header($key, $value); - } - } - - // Forward response cookies - if (!empty($client->set_cookie_headers)) { - foreach ($client->set_cookie_headers as $cookie) { - $response->header('Set-Cookie', $cookie); - } - } - - // Forward response body - $response->end($client->body); - - $client->close(); - } - - protected function initCache(): \Utopia\Cache\Cache - { - $adapter = new \Utopia\Cache\Adapter\Redis( - new \Redis() - ); - - return new \Utopia\Cache\Cache($adapter); - } - - protected function initDbPool(): \Utopia\Pools\Group - { - // Connection pool implementation - return new \Utopia\Pools\Group(); - } - - public function start(): void - { - $this->server->start(); - } - - public function getStats(): array - { - return [ - 'connections' => $this->server->stats()['connection_num'] ?? 0, - 'requests' => $this->server->stats()['request_count'] ?? 0, - 'workers' => $this->server->stats()['worker_num'] ?? 0, - 'manager' => $this->manager?->getStats() ?? [], - ]; - } -} diff --git a/src/Protocol.php b/src/Protocol.php new file mode 100644 index 0000000..27c5334 --- /dev/null +++ b/src/Protocol.php @@ -0,0 +1,13 @@ + $metadata Activity metadata + */ + public function track(string $resourceId, array $metadata = []): void; + + /** + * Invalidate cached resolution data for a resource + * + * @param string $resourceId The resource identifier + */ + public function purge(string $resourceId): void; + + /** + * Get resolver statistics + * + * @return array Statistics data + */ + public function getStats(): array; + + /** + * Called when a new connection is established + * + * @param string $resourceId The resource identifier + * @param array $metadata Additional connection metadata + */ + public function onConnect(string $resourceId, array $metadata = []): void; + + /** + * Called when a connection is closed + * + * @param string $resourceId The resource identifier + * @param array $metadata Additional disconnection metadata + */ + public function onDisconnect(string $resourceId, array $metadata = []): void; +} diff --git a/src/Resolver/Exception.php b/src/Resolver/Exception.php new file mode 100644 index 0000000..e903b59 --- /dev/null +++ b/src/Resolver/Exception.php @@ -0,0 +1,30 @@ + $context + */ + public function __construct( + string $message, + int $code = self::INTERNAL, + public readonly array $context = [] + ) { + parent::__construct($message, $code); + } +} diff --git a/src/Resolver/ReadWriteResolver.php b/src/Resolver/ReadWriteResolver.php new file mode 100644 index 0000000..fff4b3d --- /dev/null +++ b/src/Resolver/ReadWriteResolver.php @@ -0,0 +1,35 @@ + $metadata Optional metadata about the resolved backend + * @param int|null $timeout Optional connection timeout override in seconds + */ + public function __construct( + public string $endpoint, + public array $metadata = [], + public ?int $timeout = null + ) { + } +} diff --git a/src/Resource.php b/src/Resource.php deleted file mode 100644 index 5a81874..0000000 --- a/src/Resource.php +++ /dev/null @@ -1,17 +0,0 @@ -start(); + * ``` + */ +class Swoole +{ + protected Server $server; + + protected Adapter $adapter; + + /** @var array */ + protected array $config; + + /** @var array */ + protected array $backendPools = []; + + /** @var array */ + protected array $rawBackendPools = []; + + /** + * @param array $config + */ + public function __construct( + protected Resolver $resolver, + string $host = '0.0.0.0', + int $port = 80, + int $workers = 16, + array $config = [] + ) { + $this->config = array_merge([ + 'host' => $host, + 'port' => $port, + 'workers' => $workers, + 'max_connections' => 100_000, + 'max_coroutine' => 100_000, + 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB + 'buffer_output_size' => 2 * 1024 * 1024, // 2MB + 'enable_coroutine' => true, + 'max_wait_time' => 60, + 'server_mode' => SWOOLE_PROCESS, + 'reactor_num' => swoole_cpu_num() * 2, + 'dispatch_mode' => 2, + 'enable_reuse_port' => true, + 'backlog' => 65535, + 'http_parse_post' => false, + 'http_parse_cookie' => false, + 'http_parse_files' => false, + 'http_compression' => false, + 'log_level' => SWOOLE_LOG_ERROR, + 'backend_timeout' => 30, + 'backend_keep_alive' => true, + 'backend_pool_size' => 1024, + 'backend_pool_timeout' => 0.001, + 'telemetry_headers' => true, + 'fast_path' => false, + 'fast_path_assume_ok' => false, + 'fixed_backend' => null, + 'direct_response' => null, + 'direct_response_status' => 200, + 'http_keepalive_timeout' => 60, + 'open_http_protocol' => true, + 'open_http2_protocol' => false, + 'max_request' => 0, + 'raw_backend' => false, + 'raw_backend_assume_ok' => false, + 'request_handler' => null, // Custom request handler callback + 'worker_start' => null, // Worker start callback + 'worker_stop' => null, // Worker stop callback + ], $config); + + $this->server = new Server($host, $port, $this->config['server_mode']); + $this->configure(); + } + + protected function configure(): void + { + $this->server->set([ + 'worker_num' => $this->config['workers'], + 'reactor_num' => $this->config['reactor_num'], + 'max_connection' => $this->config['max_connections'], + 'max_coroutine' => $this->config['max_coroutine'], + 'socket_buffer_size' => $this->config['socket_buffer_size'], + 'buffer_output_size' => $this->config['buffer_output_size'], + 'enable_coroutine' => $this->config['enable_coroutine'], + 'max_wait_time' => $this->config['max_wait_time'], + 'open_http_protocol' => $this->config['open_http_protocol'], + 'open_http2_protocol' => $this->config['open_http2_protocol'], + 'http_keepalive_timeout' => $this->config['http_keepalive_timeout'], + 'max_request' => $this->config['max_request'], + 'dispatch_mode' => $this->config['dispatch_mode'], + 'enable_reuse_port' => $this->config['enable_reuse_port'], + 'backlog' => $this->config['backlog'], + 'http_parse_post' => $this->config['http_parse_post'], + 'http_parse_cookie' => $this->config['http_parse_cookie'], + 'http_parse_files' => $this->config['http_parse_files'], + 'http_compression' => $this->config['http_compression'], + 'log_level' => $this->config['log_level'], + + // Performance tuning + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'tcp_defer_accept' => 5, + + // Enable stats + 'task_enable_coroutine' => true, + ]); + + $this->server->on('start', $this->onStart(...)); + $this->server->on('workerStart', $this->onWorkerStart(...)); + $this->server->on('request', $this->onRequest(...)); + } + + public function onStart(Server $server): void + { + /** @var string $host */ + $host = $this->config['host']; + /** @var int $port */ + $port = $this->config['port']; + /** @var int $workers */ + $workers = $this->config['workers']; + /** @var int $maxConnections */ + $maxConnections = $this->config['max_connections']; + echo "HTTP Proxy Server started at http://{$host}:{$port}\n"; + echo "Workers: {$workers}\n"; + echo "Max connections: {$maxConnections}\n"; + } + + public function onWorkerStart(Server $server, int $workerId): void + { + $this->adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + // Apply skip_validation config if set + if (! empty($this->config['skip_validation'])) { + $this->adapter->setSkipValidation(true); + } + + // Call worker start callback if provided + $workerStartCallback = $this->config['worker_start']; + if ($workerStartCallback !== null && is_callable($workerStartCallback)) { + $workerStartCallback($server, $workerId, $this->adapter); + } + + echo "Worker #{$workerId} started (Adapter: {$this->adapter->getName()})\n"; + } + + /** + * Main request handler + * + * Performance: <1ms for cache hit + */ + public function onRequest(Request $request, Response $response): void + { + // Custom request handler takes precedence + $requestHandler = $this->config['request_handler']; + if ($requestHandler !== null && is_callable($requestHandler)) { + try { + $requestHandler($request, $response, $this->adapter); + } catch (\Throwable $e) { + error_log("Request handler error: {$e->getMessage()}"); + $response->status(500); + $response->end('Internal Server Error'); + } + + return; + } + + $startTime = null; + if ($this->config['telemetry_headers'] && ! $this->config['fast_path']) { + $startTime = microtime(true); + } + + try { + $directResponse = $this->config['direct_response']; + if ($directResponse !== null) { + /** @var int $directResponseStatus */ + $directResponseStatus = $this->config['direct_response_status']; + $response->status($directResponseStatus); + /** @var string $directResponseStr */ + $directResponseStr = $directResponse; + $response->end($directResponseStr); + + return; + } + + $fixedBackend = $this->config['fixed_backend']; + $endpoint = is_string($fixedBackend) ? $fixedBackend : null; + $result = null; + if ($endpoint === null) { + // Extract hostname from request + /** @var array $requestHeaders */ + $requestHeaders = $request->header ?? []; + $hostname = $requestHeaders['host'] ?? null; + + if (! $hostname) { + $response->status(400); + $response->end('Missing Host header'); + + return; + } + + // Validate hostname format (basic sanitization) + if (! $this->isValidHostname($hostname)) { + $response->status(400); + $response->end('Invalid Host header'); + + return; + } + + // Route to backend using adapter + $result = $this->adapter->route($hostname); + $endpoint = $result->endpoint; + } + + // Prepare telemetry data before forwarding + $telemetryData = null; + if ($this->config['telemetry_headers'] && ! $this->config['fast_path']) { + $telemetryData = [ + 'start_time' => $startTime, + 'result' => $result, + ]; + } + + // Forward request to backend (zero-copy where possible) + /** @var string $endpoint */ + if (! empty($this->config['raw_backend'])) { + $this->forwardRawRequest($request, $response, $endpoint, $telemetryData); + } else { + $this->forwardRequest($request, $response, $endpoint, $telemetryData); + } + + } catch (\Exception $e) { + // Log the full error internally + error_log("Proxy error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}"); + + // Return generic error to client (prevent information disclosure) + $response->status(503); + $response->header('Content-Type', 'application/json'); + $response->end(json_encode([ + 'error' => 'Service Unavailable', + 'message' => 'The requested service is temporarily unavailable', + ])); + } + } + + /** + * Forward HTTP request to backend using Swoole HTTP client + * + * Performance: Zero-copy streaming for large responses + * + * @param array|null $telemetryData + */ + protected function forwardRequest(Request $request, Response $response, string $endpoint, ?array $telemetryData = null): void + { + [$host, $port] = explode(':', $endpoint.':80'); + $port = (int) $port; + + $poolKey = "{$host}:{$port}"; + if (! isset($this->backendPools[$poolKey])) { + $this->backendPools[$poolKey] = new Channel($this->config['backend_pool_size']); + } + $pool = $this->backendPools[$poolKey]; + + $isNewClient = false; + $client = $pool->pop($this->config['backend_pool_timeout']); + if (! $client instanceof \Swoole\Coroutine\Http\Client) { + $client = new \Swoole\Coroutine\Http\Client($host, $port); + $client->set([ + 'timeout' => $this->config['backend_timeout'], + 'keep_alive' => $this->config['backend_keep_alive'], + ]); + $isNewClient = true; + } + + // Forward headers + if ($this->config['fast_path']) { + if ($isNewClient) { + $client->setHeaders([ + 'Host' => $port === 80 ? $host : "{$host}:{$port}", + ]); + } + } else { + $headers = []; + /** @var array $requestHeaders */ + $requestHeaders = $request->header ?? []; + foreach ($requestHeaders as $key => $value) { + $lower = strtolower($key); + if ($lower !== 'host' && $lower !== 'connection') { + $headers[$key] = $value; + } + } + $headers['Host'] = $port === 80 ? $host : "{$host}:{$port}"; + $client->setHeaders($headers); + if (! empty($request->cookie)) { + /** @var array $cookies */ + $cookies = $request->cookie; + $client->setCookies($cookies); + } + } + + // Make request + /** @var array $requestServer */ + $requestServer = $request->server ?? []; + $method = strtoupper($requestServer['request_method'] ?? 'GET'); + $path = $requestServer['request_uri'] ?? '/'; + $body = ''; + if ($method !== 'GET' && $method !== 'HEAD') { + $body = $request->getContent() ?: ''; + } + + switch ($method) { + case 'GET': + $client->get($path); + break; + case 'POST': + $client->post($path, $body); + break; + case 'HEAD': + $client->setMethod($method); + $client->execute($path); + break; + default: + $client->setMethod($method); + if ($body !== '') { + $client->setData($body); + } + $client->execute($path); + break; + } + + if (empty($this->config['fast_path_assume_ok'])) { + // Forward response + $response->status($client->statusCode); + } + + if (! $this->config['fast_path']) { + // Forward response headers + if (! empty($client->headers)) { + /** @var array $responseHeaders */ + $responseHeaders = $client->headers; + foreach ($responseHeaders as $key => $value) { + $response->header($key, $value); + } + } + + // Forward response cookies + if (! empty($client->set_cookie_headers)) { + /** @var list $cookieHeaders */ + $cookieHeaders = $client->set_cookie_headers; + foreach ($cookieHeaders as $cookie) { + $response->header('Set-Cookie', $cookie); + } + } + } + + // Add telemetry headers before ending response + if ($telemetryData !== null) { + /** @var float $startTime */ + $startTime = $telemetryData['start_time']; + $latency = round((microtime(true) - $startTime) * 1000, 2); + $response->header('X-Proxy-Latency-Ms', (string) $latency); + + $telemetryResult = $telemetryData['result'] ?? null; + if ($telemetryResult instanceof \Utopia\Proxy\ConnectionResult) { + $response->header('X-Proxy-Protocol', $telemetryResult->protocol->value); + + if (isset($telemetryResult->metadata['cached'])) { + $response->header('X-Proxy-Cache', $telemetryResult->metadata['cached'] ? 'HIT' : 'MISS'); + } + } + } + + // Forward response body + $response->end($client->body); + + if ($client->connected) { + if (! $pool->push($client, 0.001)) { + $client->close(); + } + } else { + $client->close(); + } + } + + /** + * Raw TCP HTTP forwarder for benchmark-only usage. + * + * Assumptions: + * - Backend replies with Content-Length (no chunked encoding). + * - Only GET/HEAD are supported; other methods fall back to HTTP client. + * + * @param array|null $telemetryData + */ + protected function forwardRawRequest(Request $request, Response $response, string $endpoint, ?array $telemetryData = null): void + { + /** @var array $requestServer */ + $requestServer = $request->server ?? []; + $method = strtoupper($requestServer['request_method'] ?? 'GET'); + if ($method !== 'GET' && $method !== 'HEAD') { + $this->forwardRequest($request, $response, $endpoint, $telemetryData); + + return; + } + + [$host, $port] = explode(':', $endpoint.':80'); + $port = (int) $port; + + $poolKey = "{$host}:{$port}"; + if (! isset($this->rawBackendPools[$poolKey])) { + $this->rawBackendPools[$poolKey] = new Channel($this->config['backend_pool_size']); + } + $pool = $this->rawBackendPools[$poolKey]; + + $client = $pool->pop($this->config['backend_pool_timeout']); + if (! $client instanceof CoroutineClient || ! $client->isConnected()) { + $client = new CoroutineClient(SWOOLE_SOCK_TCP); + $client->set([ + 'timeout' => $this->config['backend_timeout'], + ]); + if (! $client->connect($host, $port, $this->config['backend_timeout'])) { + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + } + + $path = $requestServer['request_uri'] ?? '/'; + $hostHeader = $port === 80 ? $host : "{$host}:{$port}"; + $requestLine = $method.' '.$path." HTTP/1.1\r\n". + 'Host: '.$hostHeader."\r\n". + "Connection: keep-alive\r\n\r\n"; + + if ($client->send($requestLine) === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + + $buffer = ''; + while (strpos($buffer, "\r\n\r\n") === false) { + /** @var string|false $chunk */ + $chunk = $client->recv(8192); + if ($chunk === '' || $chunk === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + $buffer .= $chunk; + } + + [$headerPart, $bodyPart] = explode("\r\n\r\n", $buffer, 2); + $contentLength = null; + $statusCode = 200; + $chunked = false; + + $lines = explode("\r\n", $headerPart); + if (preg_match('/^HTTP\/\\d+\\.\\d+\\s+(\\d+)/', $lines[0], $matches)) { + $statusCode = (int) $matches[1]; + } + foreach ($lines as $line) { + if (stripos($line, 'content-length:') === 0) { + $contentLength = (int) trim(substr($line, 15)); + break; + } + if (stripos($line, 'transfer-encoding:') === 0 && stripos($line, 'chunked') !== false) { + $chunked = true; + } + } + + if (! $this->config['raw_backend_assume_ok']) { + $response->status($statusCode); + } + + if ($chunked || $contentLength === null) { + // Fallback: send what we have and close connection to avoid reusing a bad state. + $response->end($bodyPart); + $client->close(); + + return; + } + + /** @var string $bodyPartStr */ + $bodyPartStr = $bodyPart; + $body = $bodyPartStr; + $remaining = $contentLength - strlen($bodyPartStr); + while ($remaining > 0) { + $chunk = $client->recv(min(8192, $remaining)); + if ($chunk === '' || $chunk === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + /** @var string $chunkStr */ + $chunkStr = $chunk; + $body .= $chunkStr; + $remaining -= strlen($chunkStr); + } + + // Add telemetry headers before ending response + if ($telemetryData !== null) { + /** @var float $startTime */ + $startTime = $telemetryData['start_time']; + $latency = round((microtime(true) - $startTime) * 1000, 2); + $response->header('X-Proxy-Latency-Ms', (string) $latency); + + $telemetryResult = $telemetryData['result'] ?? null; + if ($telemetryResult instanceof \Utopia\Proxy\ConnectionResult) { + $response->header('X-Proxy-Protocol', $telemetryResult->protocol->value); + + if (isset($telemetryResult->metadata['cached'])) { + $response->header('X-Proxy-Cache', $telemetryResult->metadata['cached'] ? 'HIT' : 'MISS'); + } + } + } + + $response->end($body); + + if ($client->isConnected()) { + if (! $pool->push($client, 0.001)) { + $client->close(); + } + } else { + $client->close(); + } + } + + /** + * Validate hostname format + */ + protected function isValidHostname(string $hostname): bool + { + // Remove port if present + $host = preg_replace('/:\d+$/', '', $hostname); + if ($host === null) { + return false; + } + + // Check for valid hostname/domain format + // Allow alphanumeric, hyphens, dots, and underscores + // Prevent injection attempts with null bytes, spaces, or other control characters + if (strlen($host) > 255 || preg_match('/[\x00-\x1f\x7f\s]/', $host)) { + return false; + } + + // Basic format validation: domain or IP + return preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host) === 1; + } + + public function start(): void + { + $this->server->start(); + } + + /** + * @return array + */ + public function getStats(): array + { + /** @var array $stats */ + $stats = $this->server->stats(); + + return [ + 'connections' => $stats['connection_num'] ?? 0, + 'requests' => $stats['request_count'] ?? 0, + 'workers' => $stats['worker_num'] ?? 0, + 'adapter' => $this->adapter->getStats(), + ]; + } +} diff --git a/src/Server/HTTP/SwooleCoroutine.php b/src/Server/HTTP/SwooleCoroutine.php new file mode 100644 index 0000000..adeb578 --- /dev/null +++ b/src/Server/HTTP/SwooleCoroutine.php @@ -0,0 +1,582 @@ +start(); + * ``` + */ +class SwooleCoroutine +{ + protected CoroutineServer $server; + + protected Adapter $adapter; + + /** @var array */ + protected array $config; + + /** @var array */ + protected array $backendPools = []; + + /** @var array */ + protected array $rawBackendPools = []; + + /** + * @param array $config + */ + public function __construct( + protected Resolver $resolver, + string $host = '0.0.0.0', + int $port = 80, + int $workers = 16, + array $config = [] + ) { + $this->config = array_merge([ + 'host' => $host, + 'port' => $port, + 'workers' => $workers, + 'max_connections' => 100_000, + 'max_coroutine' => 100_000, + 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB + 'buffer_output_size' => 2 * 1024 * 1024, // 2MB + 'enable_coroutine' => true, + 'max_wait_time' => 60, + 'server_mode' => SWOOLE_PROCESS, + 'reactor_num' => swoole_cpu_num() * 2, + 'dispatch_mode' => 2, + 'enable_reuse_port' => true, + 'backlog' => 65535, + 'http_parse_post' => false, + 'http_parse_cookie' => false, + 'http_parse_files' => false, + 'http_compression' => false, + 'log_level' => SWOOLE_LOG_ERROR, + 'backend_timeout' => 30, + 'backend_keep_alive' => true, + 'backend_pool_size' => 1024, + 'backend_pool_timeout' => 0.001, + 'telemetry_headers' => true, + 'fast_path' => false, + 'fast_path_assume_ok' => false, + 'fixed_backend' => null, + 'direct_response' => null, + 'direct_response_status' => 200, + 'http_keepalive_timeout' => 60, + 'open_http_protocol' => true, + 'open_http2_protocol' => false, + 'max_request' => 0, + 'raw_backend' => false, + 'raw_backend_assume_ok' => false, + ], $config); + + $this->initAdapter(); + $this->server = new CoroutineServer($host, $port, false, (bool) $this->config['enable_reuse_port']); + $this->configure(); + } + + protected function configure(): void + { + $this->server->set([ + 'worker_num' => $this->config['workers'], + 'reactor_num' => $this->config['reactor_num'], + 'max_connection' => $this->config['max_connections'], + 'max_coroutine' => $this->config['max_coroutine'], + 'socket_buffer_size' => $this->config['socket_buffer_size'], + 'buffer_output_size' => $this->config['buffer_output_size'], + 'enable_coroutine' => $this->config['enable_coroutine'], + 'max_wait_time' => $this->config['max_wait_time'], + 'open_http_protocol' => $this->config['open_http_protocol'], + 'open_http2_protocol' => $this->config['open_http2_protocol'], + 'http_keepalive_timeout' => $this->config['http_keepalive_timeout'], + 'max_request' => $this->config['max_request'], + 'dispatch_mode' => $this->config['dispatch_mode'], + 'enable_reuse_port' => $this->config['enable_reuse_port'], + 'backlog' => $this->config['backlog'], + 'http_parse_post' => $this->config['http_parse_post'], + 'http_parse_cookie' => $this->config['http_parse_cookie'], + 'http_parse_files' => $this->config['http_parse_files'], + 'http_compression' => $this->config['http_compression'], + 'log_level' => $this->config['log_level'], + + // Performance tuning + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'tcp_defer_accept' => 5, + + // Enable stats + 'task_enable_coroutine' => true, + ]); + $this->server->handle('/', $this->onRequest(...)); + } + + protected function initAdapter(): void + { + $this->adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + // Apply skip_validation config if set + if (! empty($this->config['skip_validation'])) { + $this->adapter->setSkipValidation(true); + } + } + + public function onStart(): void + { + /** @var string $host */ + $host = $this->config['host']; + /** @var int $port */ + $port = $this->config['port']; + /** @var int $workers */ + $workers = $this->config['workers']; + /** @var int $maxConnections */ + $maxConnections = $this->config['max_connections']; + echo "HTTP Proxy Server started at http://{$host}:{$port}\n"; + echo "Workers: {$workers}\n"; + echo "Max connections: {$maxConnections}\n"; + } + + public function onWorkerStart(int $workerId = 0): void + { + echo "Worker #{$workerId} started (Adapter: {$this->adapter->getName()})\n"; + } + + /** + * Main request handler + * + * Performance: <1ms for cache hit + */ + public function onRequest(Request $request, Response $response): void + { + $startTime = null; + if ($this->config['telemetry_headers'] && ! $this->config['fast_path']) { + $startTime = microtime(true); + } + + try { + $directResponse = $this->config['direct_response']; + if ($directResponse !== null) { + /** @var int $directResponseStatus */ + $directResponseStatus = $this->config['direct_response_status']; + $response->status($directResponseStatus); + /** @var string $directResponseStr */ + $directResponseStr = $directResponse; + $response->end($directResponseStr); + + return; + } + + $fixedBackend = $this->config['fixed_backend']; + $endpoint = is_string($fixedBackend) ? $fixedBackend : null; + $result = null; + if ($endpoint === null) { + // Extract hostname from request + /** @var array $requestHeaders */ + $requestHeaders = $request->header ?? []; + $hostname = $requestHeaders['host'] ?? null; + + if (! $hostname) { + $response->status(400); + $response->end('Missing Host header'); + + return; + } + + // Validate hostname format (basic sanitization) + if (! $this->isValidHostname($hostname)) { + $response->status(400); + $response->end('Invalid Host header'); + + return; + } + + // Route to backend using adapter + $result = $this->adapter->route($hostname); + $endpoint = $result->endpoint; + } + + // Prepare telemetry data before forwarding + $telemetryData = null; + if ($this->config['telemetry_headers'] && ! $this->config['fast_path']) { + $telemetryData = [ + 'start_time' => $startTime, + 'result' => $result, + ]; + } + + // Forward request to backend (zero-copy where possible) + /** @var string $endpoint */ + if (! empty($this->config['raw_backend'])) { + $this->forwardRawRequest($request, $response, $endpoint, $telemetryData); + } else { + $this->forwardRequest($request, $response, $endpoint, $telemetryData); + } + + } catch (\Exception $e) { + // Log the full error internally + error_log("Proxy error: {$e->getMessage()} in {$e->getFile()}:{$e->getLine()}"); + + // Return generic error to client (prevent information disclosure) + $response->status(503); + $response->header('Content-Type', 'application/json'); + $response->end(json_encode([ + 'error' => 'Service Unavailable', + 'message' => 'The requested service is temporarily unavailable', + ])); + } + } + + /** + * Forward HTTP request to backend using Swoole HTTP client + * + * Performance: Zero-copy streaming for large responses + * + * @param array|null $telemetryData + */ + protected function forwardRequest(Request $request, Response $response, string $endpoint, ?array $telemetryData = null): void + { + [$host, $port] = explode(':', $endpoint.':80'); + $port = (int) $port; + + $poolKey = "{$host}:{$port}"; + if (! isset($this->backendPools[$poolKey])) { + $this->backendPools[$poolKey] = new Channel($this->config['backend_pool_size']); + } + $pool = $this->backendPools[$poolKey]; + + $isNewClient = false; + $client = $pool->pop($this->config['backend_pool_timeout']); + if (! $client instanceof \Swoole\Coroutine\Http\Client) { + $client = new \Swoole\Coroutine\Http\Client($host, $port); + $client->set([ + 'timeout' => $this->config['backend_timeout'], + 'keep_alive' => $this->config['backend_keep_alive'], + ]); + $isNewClient = true; + } + + // Forward headers + if ($this->config['fast_path']) { + if ($isNewClient) { + $client->setHeaders([ + 'Host' => $port === 80 ? $host : "{$host}:{$port}", + ]); + } + } else { + $headers = []; + /** @var array $requestHeaders */ + $requestHeaders = $request->header ?? []; + foreach ($requestHeaders as $key => $value) { + $lower = strtolower($key); + if ($lower !== 'host' && $lower !== 'connection') { + $headers[$key] = $value; + } + } + $headers['Host'] = $port === 80 ? $host : "{$host}:{$port}"; + $client->setHeaders($headers); + if (! empty($request->cookie)) { + /** @var array $cookies */ + $cookies = $request->cookie; + $client->setCookies($cookies); + } + } + + // Make request + /** @var array $requestServer */ + $requestServer = $request->server ?? []; + $method = strtoupper($requestServer['request_method'] ?? 'GET'); + $path = $requestServer['request_uri'] ?? '/'; + $body = ''; + if ($method !== 'GET' && $method !== 'HEAD') { + $body = $request->getContent() ?: ''; + } + + switch ($method) { + case 'GET': + $client->get($path); + break; + case 'POST': + $client->post($path, $body); + break; + case 'HEAD': + $client->setMethod($method); + $client->execute($path); + break; + default: + $client->setMethod($method); + if ($body !== '') { + $client->setData($body); + } + $client->execute($path); + break; + } + + if (empty($this->config['fast_path_assume_ok'])) { + // Forward response + $response->status($client->statusCode); + } + + if (! $this->config['fast_path']) { + // Forward response headers + if (! empty($client->headers)) { + /** @var array $responseHeaders */ + $responseHeaders = $client->headers; + foreach ($responseHeaders as $key => $value) { + $response->header($key, $value); + } + } + + // Forward response cookies + if (! empty($client->set_cookie_headers)) { + /** @var list $cookieHeaders */ + $cookieHeaders = $client->set_cookie_headers; + foreach ($cookieHeaders as $cookie) { + $response->header('Set-Cookie', $cookie); + } + } + } + + // Add telemetry headers before ending response + if ($telemetryData !== null) { + /** @var float $startTime */ + $startTime = $telemetryData['start_time']; + $latency = round((microtime(true) - $startTime) * 1000, 2); + $response->header('X-Proxy-Latency-Ms', (string) $latency); + + $telemetryResult = $telemetryData['result'] ?? null; + if ($telemetryResult instanceof \Utopia\Proxy\ConnectionResult) { + $response->header('X-Proxy-Protocol', $telemetryResult->protocol->value); + + if (isset($telemetryResult->metadata['cached'])) { + $response->header('X-Proxy-Cache', $telemetryResult->metadata['cached'] ? 'HIT' : 'MISS'); + } + } + } + + // Forward response body + $response->end($client->body); + + if ($client->connected) { + if (! $pool->push($client, 0.001)) { + $client->close(); + } + } else { + $client->close(); + } + } + + /** + * Raw TCP HTTP forwarder for benchmark-only usage. + * + * Assumptions: + * - Backend replies with Content-Length (no chunked encoding). + * - Only GET/HEAD are supported; other methods fall back to HTTP client. + * + * @param array|null $telemetryData + */ + protected function forwardRawRequest(Request $request, Response $response, string $endpoint, ?array $telemetryData = null): void + { + /** @var array $requestServer */ + $requestServer = $request->server ?? []; + $method = strtoupper($requestServer['request_method'] ?? 'GET'); + if ($method !== 'GET' && $method !== 'HEAD') { + $this->forwardRequest($request, $response, $endpoint, $telemetryData); + + return; + } + + [$host, $port] = explode(':', $endpoint.':80'); + $port = (int) $port; + + $poolKey = "{$host}:{$port}"; + if (! isset($this->rawBackendPools[$poolKey])) { + $this->rawBackendPools[$poolKey] = new Channel($this->config['backend_pool_size']); + } + $pool = $this->rawBackendPools[$poolKey]; + + $client = $pool->pop($this->config['backend_pool_timeout']); + if (! $client instanceof CoroutineClient || ! $client->isConnected()) { + $client = new CoroutineClient(SWOOLE_SOCK_TCP); + $client->set([ + 'timeout' => $this->config['backend_timeout'], + ]); + if (! $client->connect($host, $port, $this->config['backend_timeout'])) { + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + } + + $path = $requestServer['request_uri'] ?? '/'; + $hostHeader = $port === 80 ? $host : "{$host}:{$port}"; + $requestLine = $method.' '.$path." HTTP/1.1\r\n". + 'Host: '.$hostHeader."\r\n". + "Connection: keep-alive\r\n\r\n"; + + if ($client->send($requestLine) === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + + $buffer = ''; + while (strpos($buffer, "\r\n\r\n") === false) { + /** @var string|false $chunk */ + $chunk = $client->recv(8192); + if ($chunk === '' || $chunk === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + $buffer .= $chunk; + } + + [$headerPart, $bodyPart] = explode("\r\n\r\n", $buffer, 2); + $contentLength = null; + $statusCode = 200; + $chunked = false; + + $lines = explode("\r\n", $headerPart); + if (preg_match('/^HTTP\/\\d+\\.\\d+\\s+(\\d+)/', $lines[0], $matches)) { + $statusCode = (int) $matches[1]; + } + foreach ($lines as $line) { + if (stripos($line, 'content-length:') === 0) { + $contentLength = (int) trim(substr($line, 15)); + break; + } + if (stripos($line, 'transfer-encoding:') === 0 && stripos($line, 'chunked') !== false) { + $chunked = true; + } + } + + if (! $this->config['raw_backend_assume_ok']) { + $response->status($statusCode); + } + + if ($chunked || $contentLength === null) { + // Fallback: send what we have and close connection to avoid reusing a bad state. + $response->end($bodyPart); + $client->close(); + + return; + } + + /** @var string $bodyPartStr */ + $bodyPartStr = $bodyPart; + $body = $bodyPartStr; + $remaining = $contentLength - strlen($bodyPartStr); + while ($remaining > 0) { + $chunk = $client->recv(min(8192, $remaining)); + if ($chunk === '' || $chunk === false) { + $client->close(); + $response->status(502); + $response->end('Bad Gateway'); + + return; + } + /** @var string $chunkStr */ + $chunkStr = $chunk; + $body .= $chunkStr; + $remaining -= strlen($chunkStr); + } + + // Add telemetry headers before ending response + if ($telemetryData !== null) { + /** @var float $startTime */ + $startTime = $telemetryData['start_time']; + $latency = round((microtime(true) - $startTime) * 1000, 2); + $response->header('X-Proxy-Latency-Ms', (string) $latency); + + $telemetryResult = $telemetryData['result'] ?? null; + if ($telemetryResult instanceof \Utopia\Proxy\ConnectionResult) { + $response->header('X-Proxy-Protocol', $telemetryResult->protocol->value); + + if (isset($telemetryResult->metadata['cached'])) { + $response->header('X-Proxy-Cache', $telemetryResult->metadata['cached'] ? 'HIT' : 'MISS'); + } + } + } + + $response->end($body); + + if ($client->isConnected()) { + if (! $pool->push($client, 0.001)) { + $client->close(); + } + } else { + $client->close(); + } + } + + /** + * Validate hostname format + */ + protected function isValidHostname(string $hostname): bool + { + // Remove port if present + $host = preg_replace('/:\d+$/', '', $hostname); + if ($host === null) { + return false; + } + + // Check for valid hostname/domain format + // Allow alphanumeric, hyphens, dots, and underscores + // Prevent injection attempts with null bytes, spaces, or other control characters + if (strlen($host) > 255 || preg_match('/[\x00-\x1f\x7f\s]/', $host)) { + return false; + } + + // Basic format validation: domain or IP + return preg_match('/^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$/i', $host) === 1; + } + + public function start(): void + { + if (\Swoole\Coroutine::getCid() > 0) { + $this->onStart(); + $this->onWorkerStart(0); + $this->server->start(); + + return; + } + + \Swoole\Coroutine\run(function (): void { + $this->onStart(); + $this->onWorkerStart(0); + $this->server->start(); + }); + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'connections' => 0, + 'requests' => 0, + 'workers' => 1, + 'adapter' => $this->adapter->getStats(), + ]; + } +} diff --git a/src/Smtp/SmtpServer.php b/src/Server/SMTP/Swoole.php similarity index 57% rename from src/Smtp/SmtpServer.php rename to src/Server/SMTP/Swoole.php index 9487296..378aff8 100644 --- a/src/Smtp/SmtpServer.php +++ b/src/Server/SMTP/Swoole.php @@ -1,22 +1,41 @@ start(); + * ``` */ -class SmtpServer +class Swoole { protected Server $server; - protected SmtpConnectionManager $manager; + + protected Adapter $adapter; + + /** @var array */ protected array $config; + /** @var array */ + protected array $connections = []; + + /** + * @param array $config + */ public function __construct( + protected Resolver $resolver, string $host = '0.0.0.0', int $port = 25, int $workers = 16, @@ -26,8 +45,8 @@ public function __construct( 'host' => $host, 'port' => $port, 'workers' => $workers, - 'max_connections' => 50000, - 'max_coroutine' => 50000, + 'max_connections' => 50_000, + 'max_coroutine' => 50_000, 'socket_buffer_size' => 2 * 1024 * 1024, // 2MB 'buffer_output_size' => 2 * 1024 * 1024, 'enable_coroutine' => true, @@ -53,7 +72,6 @@ protected function configure(): void 'open_tcp_nodelay' => true, 'tcp_fastopen' => true, 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, // SMTP-specific settings 'open_length_check' => false, // SMTP uses CRLF line endings @@ -64,33 +82,42 @@ protected function configure(): void 'task_enable_coroutine' => true, ]); - $this->server->on('start', [$this, 'onStart']); - $this->server->on('workerStart', [$this, 'onWorkerStart']); - $this->server->on('connect', [$this, 'onConnect']); - $this->server->on('receive', [$this, 'onReceive']); - $this->server->on('close', [$this, 'onClose']); + $this->server->on('start', $this->onStart(...)); + $this->server->on('workerStart', $this->onWorkerStart(...)); + $this->server->on('connect', $this->onConnect(...)); + $this->server->on('receive', $this->onReceive(...)); + $this->server->on('close', $this->onClose(...)); } public function onStart(Server $server): void { - echo "SMTP Proxy Server started at {$this->config['host']}:{$this->config['port']}\n"; - echo "Workers: {$this->config['workers']}\n"; - echo "Max connections: {$this->config['max_connections']}\n"; + /** @var string $host */ + $host = $this->config['host']; + /** @var int $port */ + $port = $this->config['port']; + /** @var int $workers */ + $workers = $this->config['workers']; + /** @var int $maxConnections */ + $maxConnections = $this->config['max_connections']; + echo "SMTP Proxy Server started at {$host}:{$port}\n"; + echo "Workers: {$workers}\n"; + echo "Max connections: {$maxConnections}\n"; } public function onWorkerStart(Server $server, int $workerId): void { - // Initialize connection manager per worker - $this->manager = new SmtpConnectionManager( - cache: $this->initCache(), - dbPool: $this->initDbPool(), - computeApiUrl: $this->config['compute_api_url'] ?? 'http://appwrite-api/v1/compute', - computeApiKey: $this->config['compute_api_key'] ?? '', - coldStartTimeout: $this->config['cold_start_timeout'] ?? 30000, - healthCheckInterval: $this->config['health_check_interval'] ?? 100 + $this->adapter = new Adapter( + $this->resolver, + name: 'SMTP', + protocol: Protocol::SMTP ); - echo "Worker #{$workerId} started\n"; + // Apply skip_validation config if set + if (! empty($this->config['skip_validation'])) { + $this->adapter->setSkipValidation(true); + } + + echo "Worker #{$workerId} started (Adapter: {$this->adapter->getName()})\n"; } /** @@ -101,13 +128,13 @@ public function onConnect(Server $server, int $fd, int $reactorId): void echo "Client #{$fd} connected\n"; // Send SMTP greeting - $server->send($fd, "220 appwrite.io ESMTP Proxy\r\n"); + $server->send($fd, "220 utopia-php.io ESMTP Proxy\r\n"); // Initialize connection state - $server->connections[$fd] = [ + $this->connections[$fd] = [ 'state' => 'greeting', 'domain' => null, - 'backend_fd' => null, + 'backend' => null, ]; } @@ -119,7 +146,15 @@ public function onConnect(Server $server, int $fd, int $reactorId): void public function onReceive(Server $server, int $fd, int $reactorId, string $data): void { try { - $conn = &$server->connections[$fd]; + if (! isset($this->connections[$fd])) { + $this->connections[$fd] = [ + 'state' => 'greeting', + 'domain' => null, + 'backend' => null, + ]; + } + + $conn = &$this->connections[$fd]; // Parse SMTP command $command = strtoupper(substr(trim($data), 0, 4)); @@ -152,6 +187,8 @@ public function onReceive(Server $server, int $fd, int $reactorId, string $data) /** * Handle EHLO/HELO - extract domain and route to backend + * + * @param array{state: string, domain: ?string, backend: ?Client} $conn */ protected function handleHelo(Server $server, int $fd, string $data, array &$conn): void { @@ -160,12 +197,12 @@ protected function handleHelo(Server $server, int $fd, string $data, array &$con $domain = $matches[2]; $conn['domain'] = $domain; - // Get backend connection - $result = $this->manager->handleConnection($domain); + // Route to backend using adapter + $result = $this->adapter->route($domain); // Connect to backend SMTP server - $backendFd = $this->connectToBackend($result->endpoint, 25); - $conn['backend_fd'] = $backendFd; + $backendClient = $this->connectToBackend($result->endpoint, 25); + $conn['backend'] = $backendClient; // Forward EHLO to backend and relay response $this->forwardToBackend($server, $fd, $data, $conn); @@ -177,21 +214,23 @@ protected function handleHelo(Server $server, int $fd, string $data, array &$con /** * Forward command to backend SMTP server + * + * @param array{state: string, domain: ?string, backend: ?Client} $conn */ protected function forwardToBackend(Server $server, int $fd, string $data, array &$conn): void { - if (!isset($conn['backend_fd'])) { + if (! isset($conn['backend'])) { throw new \Exception('No backend connection'); } - $backendFd = $conn['backend_fd']; + $backendClient = $conn['backend']; // Send to backend - $server->send($backendFd, $data); + $backendClient->send($data); // Relay response back to client (in coroutine) - Coroutine::create(function () use ($server, $fd, $backendFd) { - $response = $server->recv($backendFd, 8192, 5); + Coroutine::create(function () use ($server, $fd, $backendClient) { + $response = $backendClient->recv(8192); if ($response !== false && $response !== '') { $server->send($fd, $response); @@ -202,21 +241,25 @@ protected function forwardToBackend(Server $server, int $fd, string $data, array /** * Connect to backend SMTP server */ - protected function connectToBackend(string $endpoint, int $port): int + protected function connectToBackend(string $endpoint, int $port): Client { - [$host, $port] = explode(':', $endpoint . ':' . $port); - $port = (int)$port; + [$host, $port] = explode(':', $endpoint.':'.$port); + $port = (int) $port; - $client = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP); + $client = new Client(SWOOLE_SOCK_TCP); - if (!$client->connect($host, $port, 30)) { + if (! $client->connect($host, $port, 30)) { throw new \Exception("Failed to connect to backend SMTP: {$host}:{$port}"); } + $client->set([ + 'timeout' => 5, + ]); + // Read backend greeting - $greeting = $client->recv(8192, 5); + $client->recv(8192); - return $client->sock; + return $client; } public function onClose(Server $server, int $fd, int $reactorId): void @@ -224,24 +267,11 @@ public function onClose(Server $server, int $fd, int $reactorId): void echo "Client #{$fd} disconnected\n"; // Close backend connection if exists - if (isset($server->connections[$fd]['backend_fd'])) { - $server->close($server->connections[$fd]['backend_fd']); + if (isset($this->connections[$fd]['backend'])) { + $this->connections[$fd]['backend']->close(); } - } - protected function initCache(): \Utopia\Cache\Cache - { - $redis = new \Redis(); - $redis->connect($this->config['redis_host'] ?? '127.0.0.1', $this->config['redis_port'] ?? 6379); - - $adapter = new \Utopia\Cache\Adapter\Redis($redis); - return new \Utopia\Cache\Cache($adapter); - } - - protected function initDbPool(): \Utopia\Pools\Group - { - // Connection pool implementation - return new \Utopia\Pools\Group(); + unset($this->connections[$fd]); } public function start(): void @@ -249,13 +279,21 @@ public function start(): void $this->server->start(); } + /** + * @return array + */ public function getStats(): array { + /** @var array $serverStats */ + $serverStats = $this->server->stats(); + /** @var array $coroutineStats */ + $coroutineStats = Coroutine::stats(); + return [ - 'connections' => $this->server->stats()['connection_num'] ?? 0, - 'workers' => $this->server->stats()['worker_num'] ?? 0, - 'coroutines' => Coroutine::stats()['coroutine_num'] ?? 0, - 'manager' => $this->manager?->getStats() ?? [], + 'connections' => $serverStats['connection_num'] ?? 0, + 'workers' => $serverStats['worker_num'] ?? 0, + 'coroutines' => $coroutineStats['coroutine_num'] ?? 0, + 'adapter' => $this->adapter->getStats(), ]; } } diff --git a/src/Server/TCP/Config.php b/src/Server/TCP/Config.php new file mode 100644 index 0000000..40149b2 --- /dev/null +++ b/src/Server/TCP/Config.php @@ -0,0 +1,60 @@ + $ports + */ + public function __construct( + public readonly string $host = '0.0.0.0', + public readonly array $ports = [5432, 3306, 27017], + public readonly int $workers = 16, + public readonly int $maxConnections = 200_000, + public readonly int $maxCoroutine = 200_000, + public readonly int $socketBufferSize = 16 * 1024 * 1024, + public readonly int $bufferOutputSize = 16 * 1024 * 1024, + ?int $reactorNum = null, + public readonly int $dispatchMode = 2, + public readonly bool $enableReusePort = true, + public readonly int $backlog = 65535, + public readonly int $packageMaxLength = 32 * 1024 * 1024, + public readonly int $tcpKeepidle = 30, + public readonly int $tcpKeepinterval = 10, + public readonly int $tcpKeepcount = 3, + public readonly bool $enableCoroutine = true, + public readonly int $maxWaitTime = 60, + public readonly int $logLevel = SWOOLE_LOG_ERROR, + public readonly bool $logConnections = false, + public readonly int $recvBufferSize = 131072, + public readonly float $backendConnectTimeout = 5.0, + public readonly bool $skipValidation = false, + public readonly bool $readWriteSplit = false, + public readonly ?TLS $tls = null, + ) { + $this->reactorNum = $reactorNum ?? swoole_cpu_num() * 2; + } + + /** + * Check if TLS termination is enabled + */ + public function isTlsEnabled(): bool + { + return $this->tls !== null; + } + + /** + * Get the TLS context builder, or null if TLS is not configured + */ + public function getTlsContext(): ?TlsContext + { + if ($this->tls === null) { + return null; + } + + return new TlsContext($this->tls); + } +} diff --git a/src/Server/TCP/Swoole.php b/src/Server/TCP/Swoole.php new file mode 100644 index 0000000..d4adf76 --- /dev/null +++ b/src/Server/TCP/Swoole.php @@ -0,0 +1,438 @@ +start(); + * ``` + */ +class Swoole +{ + protected Server $server; + + /** @var array */ + protected array $adapters = []; + + protected Config $config; + + protected ?TlsContext $tlsContext = null; + + /** @var array */ + protected array $forwarding = []; + + /** @var array Primary/default backend connections */ + protected array $backendClients = []; + + /** @var array Read replica backend connections (when read/write split enabled) */ + protected array $readBackendClients = []; + + /** @var array */ + protected array $clientDatabaseIds = []; + + /** @var array */ + protected array $clientPorts = []; + + /** + * Tracks connections awaiting TLS upgrade (PostgreSQL STARTTLS). + * After sending 'S' in response to SSLRequest, the connection + * must complete the TLS handshake before we see the real startup message. + * + * @var array + */ + protected array $pendingTlsUpgrade = []; + + public function __construct( + protected Resolver $resolver, + ?Config $config = null, + ) { + $this->config = $config ?? new Config(); + + if ($this->config->isTlsEnabled()) { + /** @var TLS $tls */ + $tls = $this->config->tls; + $tls->validate(); + $this->tlsContext = $this->config->getTlsContext(); + } + + $socketType = $this->tlsContext !== null + ? $this->tlsContext->getSocketType() + : SWOOLE_SOCK_TCP; + + // Create main server on first port + $this->server = new Server( + $this->config->host, + $this->config->ports[0], + SWOOLE_PROCESS, + $socketType, + ); + + // Add listeners for additional ports + for ($i = 1; $i < count($this->config->ports); $i++) { + $this->server->addlistener( + $this->config->host, + $this->config->ports[$i], + $socketType, + ); + } + + $this->configure(); + } + + protected function configure(): void + { + $settings = [ + 'worker_num' => $this->config->workers, + 'reactor_num' => $this->config->reactorNum, + 'max_connection' => $this->config->maxConnections, + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'buffer_output_size' => $this->config->bufferOutputSize, + 'enable_coroutine' => $this->config->enableCoroutine, + 'max_wait_time' => $this->config->maxWaitTime, + 'log_level' => $this->config->logLevel, + 'dispatch_mode' => $this->config->dispatchMode, + 'enable_reuse_port' => $this->config->enableReusePort, + 'backlog' => $this->config->backlog, + + // TCP performance tuning + 'open_tcp_nodelay' => true, + 'tcp_fastopen' => true, + 'open_cpu_affinity' => true, + 'tcp_defer_accept' => 5, + 'open_tcp_keepalive' => true, + 'tcp_keepidle' => $this->config->tcpKeepidle, + 'tcp_keepinterval' => $this->config->tcpKeepinterval, + 'tcp_keepcount' => $this->config->tcpKeepcount, + + // Package settings for database protocols + 'open_length_check' => false, // Let database handle framing + 'package_max_length' => $this->config->packageMaxLength, + + // Enable stats + 'task_enable_coroutine' => true, + ]; + + // Apply TLS settings when enabled + if ($this->tlsContext !== null) { + $settings = array_merge($settings, $this->tlsContext->toSwooleConfig()); + } + + $this->server->set($settings); + + $this->server->on('start', $this->onStart(...)); + $this->server->on('workerStart', $this->onWorkerStart(...)); + $this->server->on('connect', $this->onConnect(...)); + $this->server->on('receive', $this->onReceive(...)); + $this->server->on('close', $this->onClose(...)); + } + + public function onStart(Server $server): void + { + echo "TCP Proxy Server started at {$this->config->host}\n"; + echo 'Ports: '.implode(', ', $this->config->ports)."\n"; + echo "Workers: {$this->config->workers}\n"; + echo "Max connections: {$this->config->maxConnections}\n"; + + if ($this->config->isTlsEnabled()) { + echo "TLS: enabled\n"; + if ($this->config->tls?->isMutualTLS()) { + echo "mTLS: enabled (client certificates required)\n"; + } + } + } + + public function onWorkerStart(Server $server, int $workerId): void + { + // Initialize TCP adapter per worker per port + foreach ($this->config->ports as $port) { + $adapter = new TCPAdapter($this->resolver, port: $port); + + if ($this->config->skipValidation) { + $adapter->setSkipValidation(true); + } + + $adapter->setConnectTimeout($this->config->backendConnectTimeout); + + if ($this->config->readWriteSplit) { + $adapter->setReadWriteSplit(true); + } + + $this->adapters[$port] = $adapter; + } + + echo "Worker #{$workerId} started\n"; + } + + /** + * Handle new TCP connection + */ + public function onConnect(Server $server, int $fd, int $reactorId): void + { + /** @var array $info */ + $info = $server->getClientInfo($fd); + /** @var int $port */ + $port = $info['server_port'] ?? 0; + $this->clientPorts[$fd] = $port; + + if ($this->config->logConnections) { + echo "Client #{$fd} connected to port {$port}\n"; + } + } + + /** + * Main receive handler + * + * Performance: <1ms overhead for proxying + * + * When TLS is enabled, handles protocol-specific SSL negotiation: + * - PostgreSQL: Intercepts SSLRequest, responds 'S', Swoole upgrades to TLS + * - MySQL: Swoole handles SSL natively via SWOOLE_SSL socket type + */ + public function onReceive(Server $server, int $fd, int $reactorId, string $data): void + { + // Fast path: existing connection - forward to appropriate backend + if (isset($this->backendClients[$fd])) { + $databaseId = $this->clientDatabaseIds[$fd] ?? null; + $port = $this->clientPorts[$fd] ?? 5432; + $adapter = $this->adapters[$port] ?? null; + + // Record inbound bytes and track activity + if ($databaseId !== null && $adapter !== null) { + $adapter->recordBytes($databaseId, \strlen($data), 0); + $adapter->track($databaseId); + } + + // When read/write split is active and we have a read backend, classify and route + if (isset($this->readBackendClients[$fd]) && $adapter !== null) { + $queryType = $adapter->classifyQuery($data, $fd); + + if ($queryType === QueryType::Read) { + $this->readBackendClients[$fd]->send($data); + + return; + } + } + + $this->backendClients[$fd]->send($data); + + return; + } + + // Handle PostgreSQL STARTTLS: SSLRequest comes before the real startup message. + // When TLS is enabled with Swoole's native SSL, the TLS handshake happens at the + // transport level. However, PostgreSQL clients send an SSLRequest message first + // (at the application layer) to negotiate TLS. We intercept this, respond with 'S' + // to indicate willingness, and then Swoole handles the actual TLS upgrade. + // The next onReceive call will contain the real startup message over TLS. + if ($this->tlsContext !== null && TLS::isPostgreSQLSSLRequest($data)) { + $port = $this->clientPorts[$fd] ?? null; + if ($port !== null && $port === 5432) { + // Respond with 'S' to indicate SSL is supported, then Swoole + // handles the TLS handshake natively on the already-SSL socket + $server->send($fd, TLS::PG_SSL_RESPONSE_OK); + $this->pendingTlsUpgrade[$fd] = true; + + return; + } + } + + // After PostgreSQL SSLRequest -> 'S' response, the client performs the TLS + // handshake (handled by Swoole at transport level), then sends the real + // startup message. Clear the pending flag and continue to normal processing. + if (isset($this->pendingTlsUpgrade[$fd])) { + unset($this->pendingTlsUpgrade[$fd]); + } + + // Slow path: new connection setup + try { + $port = $this->clientPorts[$fd] ?? null; + if ($port === null) { + /** @var array $info */ + $info = $server->getClientInfo($fd); + /** @var int $port */ + $port = $info['server_port'] ?? 0; + if ($port === 0) { + throw new \Exception('Missing server port for connection'); + } + $this->clientPorts[$fd] = $port; + } + + $adapter = $this->adapters[$port] ?? null; + if ($adapter === null) { + throw new \Exception("No adapter registered for port {$port}"); + } + + // Parse database ID from initial packet + $databaseId = $adapter->parseDatabaseId($data, $fd); + $this->clientDatabaseIds[$fd] = $databaseId; + + // Get primary backend connection + $backendClient = $adapter->getBackendConnection($databaseId, $fd); + $this->backendClients[$fd] = $backendClient; + + // If read/write split is enabled, establish read replica connection + if ($adapter->isReadWriteSplit() && $this->resolver instanceof ReadWriteResolver) { + try { + $readResult = $adapter->routeQuery($databaseId, QueryType::Read); + $readEndpoint = $readResult->endpoint; + [$readHost, $readPort] = \explode(':', $readEndpoint . ':' . $port); + + // Only create separate read connection if it differs from the write endpoint + $writeResult = $adapter->routeQuery($databaseId, QueryType::Write); + if ($readEndpoint !== $writeResult->endpoint) { + $readClient = new \Swoole\Coroutine\Client(SWOOLE_SOCK_TCP); + $readClient->set([ + 'timeout' => $this->config->backendConnectTimeout, + 'connect_timeout' => $this->config->backendConnectTimeout, + 'open_tcp_nodelay' => true, + 'socket_buffer_size' => 2 * 1024 * 1024, + ]); + + if ($readClient->connect($readHost, (int) $readPort, $this->config->backendConnectTimeout)) { + $this->readBackendClients[$fd] = $readClient; + // Forward initial startup message to read replica too + $readClient->send($data); + // Start forwarding from read replica back to client + $this->startForwarding($server, $fd, $readClient); + } + } + } catch (\Exception $e) { + // Read replica unavailable β€” all traffic goes to primary + if ($this->config->logConnections) { + echo "Read replica unavailable for #{$fd}: {$e->getMessage()}\n"; + } + } + } + + // Notify connect callback + $adapter->notifyConnect($databaseId); + + // Forward initial data to primary + $backendClient->send($data); + + // Start bidirectional forwarding from primary + $this->forwarding[$fd] = true; + $this->startForwarding($server, $fd, $backendClient); + + } catch (\Exception $e) { + echo "Error handling data from #{$fd}: {$e->getMessage()}\n"; + $server->close($fd); + } + } + + /** + * Bidirectional forwarding loop - ZERO-COPY + * + * Performance: 10GB/s+ throughput + */ + protected function startForwarding(Server $server, int $clientFd, Client $backendClient): void + { + $bufferSize = $this->config->recvBufferSize; + /** @var \Swoole\Coroutine\Socket $backendSocket */ + $backendSocket = $backendClient->exportSocket(); + + $databaseId = $this->clientDatabaseIds[$clientFd] ?? null; + $port = $this->clientPorts[$clientFd] ?? null; + $adapter = ($port !== null) ? ($this->adapters[$port] ?? null) : null; + + Coroutine::create(function () use ($server, $clientFd, $backendSocket, $bufferSize, $databaseId, $adapter) { + while ($server->exist($clientFd)) { + /** @var string|false $data */ + $data = $backendSocket->recv($bufferSize); + if ($data === false || $data === '') { + break; + } + if ($databaseId !== null && $adapter !== null) { + $adapter->recordBytes($databaseId, 0, \strlen($data)); + } + $server->send($clientFd, $data); + } + }); + } + + public function onClose(Server $server, int $fd, int $reactorId): void + { + if ($this->config->logConnections) { + echo "Client #{$fd} disconnected\n"; + } + + if (isset($this->backendClients[$fd])) { + $this->backendClients[$fd]->close(); + unset($this->backendClients[$fd]); + } + + if (isset($this->readBackendClients[$fd])) { + $this->readBackendClients[$fd]->close(); + unset($this->readBackendClients[$fd]); + } + + // Clean up adapter's connection pool and transaction pinning state + if (isset($this->clientDatabaseIds[$fd]) && isset($this->clientPorts[$fd])) { + $port = $this->clientPorts[$fd]; + $databaseId = $this->clientDatabaseIds[$fd]; + $adapter = $this->adapters[$port] ?? null; + if ($adapter) { + $adapter->notifyClose($databaseId); + $adapter->closeBackendConnection($databaseId, $fd); + $adapter->clearConnectionState($fd); + } + } + + unset($this->forwarding[$fd]); + unset($this->clientDatabaseIds[$fd]); + unset($this->clientPorts[$fd]); + unset($this->pendingTlsUpgrade[$fd]); + } + + public function start(): void + { + $this->server->start(); + } + + /** + * @return array + */ + public function getStats(): array + { + $adapterStats = []; + foreach ($this->adapters as $port => $adapter) { + $adapterStats[$port] = $adapter->getStats(); + } + + /** @var array $serverStats */ + $serverStats = $this->server->stats(); + /** @var array $coroutineStats */ + $coroutineStats = Coroutine::stats(); + + return [ + 'connections' => $serverStats['connection_num'] ?? 0, + 'workers' => $serverStats['worker_num'] ?? 0, + 'coroutines' => $coroutineStats['coroutine_num'] ?? 0, + 'adapters' => $adapterStats, + ]; + } +} diff --git a/src/Server/TCP/SwooleCoroutine.php b/src/Server/TCP/SwooleCoroutine.php new file mode 100644 index 0000000..81a422a --- /dev/null +++ b/src/Server/TCP/SwooleCoroutine.php @@ -0,0 +1,275 @@ +start(); + * ``` + */ +class SwooleCoroutine +{ + /** @var array */ + protected array $servers = []; + + /** @var array */ + protected array $adapters = []; + + protected Config $config; + + protected ?TlsContext $tlsContext = null; + + public function __construct( + protected Resolver $resolver, + ?Config $config = null, + ) { + $this->config = $config ?? new Config(); + + if ($this->config->isTlsEnabled()) { + /** @var TLS $tls */ + $tls = $this->config->tls; + $tls->validate(); + $this->tlsContext = $this->config->getTlsContext(); + } + + $this->initAdapters(); + $this->configureServers(); + } + + protected function initAdapters(): void + { + foreach ($this->config->ports as $port) { + $adapter = new TCPAdapter($this->resolver, port: $port); + + if ($this->config->skipValidation) { + $adapter->setSkipValidation(true); + } + + $adapter->setConnectTimeout($this->config->backendConnectTimeout); + + $this->adapters[$port] = $adapter; + } + } + + protected function configureServers(): void + { + // Global coroutine settings + Coroutine::set([ + 'max_coroutine' => $this->config->maxCoroutine, + 'socket_buffer_size' => $this->config->socketBufferSize, + 'log_level' => $this->config->logLevel, + ]); + + $ssl = $this->tlsContext !== null; + + foreach ($this->config->ports as $port) { + $server = new CoroutineServer($this->config->host, $port, $ssl, $this->config->enableReusePort); + + // Only socket-protocol settings are applicable to Coroutine\Server + $settings = [ + 'open_tcp_nodelay' => true, + 'open_tcp_keepalive' => true, + 'tcp_keepidle' => $this->config->tcpKeepidle, + 'tcp_keepinterval' => $this->config->tcpKeepinterval, + 'tcp_keepcount' => $this->config->tcpKeepcount, + 'open_length_check' => false, + 'package_max_length' => $this->config->packageMaxLength, + 'buffer_output_size' => $this->config->bufferOutputSize, + ]; + + // Apply TLS settings when enabled + if ($this->tlsContext !== null) { + $settings = array_merge($settings, $this->tlsContext->toSwooleConfig()); + } + + $server->set($settings); + + // Coroutine\Server::start() already spawns a coroutine per connection + $server->handle(function (Connection $connection) use ($port): void { + $this->handleConnection($connection, $port); + }); + + $this->servers[$port] = $server; + } + } + + public function onStart(): void + { + echo "TCP Proxy Server started at {$this->config->host}\n"; + echo 'Ports: '.implode(', ', $this->config->ports)."\n"; + echo "Workers: {$this->config->workers}\n"; + echo "Max connections: {$this->config->maxConnections}\n"; + + if ($this->config->isTlsEnabled()) { + echo "TLS: enabled\n"; + if ($this->config->tls?->isMutualTLS()) { + echo "mTLS: enabled (client certificates required)\n"; + } + } + } + + public function onWorkerStart(int $workerId = 0): void + { + echo "Worker #{$workerId} started\n"; + } + + protected function handleConnection(Connection $connection, int $port): void + { + /** @var \Swoole\Coroutine\Socket $clientSocket */ + $clientSocket = $connection->exportSocket(); + $clientId = spl_object_id($connection); + $adapter = $this->adapters[$port]; + $bufferSize = $this->config->recvBufferSize; + + if ($this->config->logConnections) { + echo "Client #{$clientId} connected to port {$port}\n"; + } + + // Wait for first packet to establish backend connection + /** @var string|false $data */ + $data = $clientSocket->recv($bufferSize); + if ($data === false || $data === '') { + $clientSocket->close(); + + return; + } + + // Handle PostgreSQL STARTTLS negotiation. + // PG clients send an SSLRequest before the real startup message. + // When TLS is enabled with Swoole's coroutine SSL server, the TLS + // handshake is handled at the transport level. We respond with 'S' + // to satisfy the PG protocol, then read the real startup message. + if ($this->tlsContext !== null && $port === 5432 && TLS::isPostgreSQLSSLRequest($data)) { + $clientSocket->sendAll(TLS::PG_SSL_RESPONSE_OK); + + // The TLS handshake is handled by Swoole at the transport layer. + // Read the real startup message that follows. + /** @var string|false $data */ + $data = $clientSocket->recv($bufferSize); + if ($data === false || $data === '') { + $clientSocket->close(); + + return; + } + } + + try { + $databaseId = $adapter->parseDatabaseId($data, $clientId); + $backendClient = $adapter->getBackendConnection($databaseId, $clientId); + /** @var \Swoole\Coroutine\Socket $backendSocket */ + $backendSocket = $backendClient->exportSocket(); + + // Notify connect + $adapter->notifyConnect($databaseId); + + // Start backend -> client forwarding in separate coroutine + Coroutine::create(function () use ($clientSocket, $backendSocket, $bufferSize, $adapter, $databaseId): void { + while (true) { + /** @var string|false $data */ + $data = $backendSocket->recv($bufferSize); + if ($data === false || $data === '') { + break; + } + $adapter->recordBytes($databaseId, 0, \strlen($data)); + if ($clientSocket->sendAll($data) === false) { + break; + } + } + $clientSocket->close(); + }); + + // Forward initial packet + $adapter->recordBytes($databaseId, \strlen($data), 0); + $backendSocket->sendAll($data); + } catch (\Exception $e) { + echo "Error handling data from #{$clientId}: {$e->getMessage()}\n"; + $clientSocket->close(); + + return; + } + + // Client -> backend forwarding in current coroutine + while (true) { + /** @var string|false $data */ + $data = $clientSocket->recv($bufferSize); + if ($data === false || $data === '') { + break; + } + $adapter->recordBytes($databaseId, \strlen($data), 0); + $adapter->track($databaseId); + $backendSocket->sendAll($data); + } + + $adapter->notifyClose($databaseId); + $backendSocket->close(); + $adapter->closeBackendConnection($databaseId, $clientId); + + if ($this->config->logConnections) { + echo "Client #{$clientId} disconnected\n"; + } + } + + public function start(): void + { + $runner = function (): void { + $this->onStart(); + $this->onWorkerStart(0); + + foreach ($this->servers as $server) { + Coroutine::create(function () use ($server): void { + $server->start(); + }); + } + }; + + if (Coroutine::getCid() > 0) { + $runner(); + + return; + } + + \Swoole\Coroutine\run($runner); + } + + /** + * @return array + */ + public function getStats(): array + { + $adapterStats = []; + foreach ($this->adapters as $port => $adapter) { + $adapterStats[$port] = $adapter->getStats(); + } + + /** @var array $coroutineStats */ + $coroutineStats = Coroutine::stats(); + + return [ + 'connections' => 0, + 'workers' => 1, + 'coroutines' => $coroutineStats['coroutine_num'] ?? 0, + 'adapters' => $adapterStats, + ]; + } +} diff --git a/src/Server/TCP/TLS.php b/src/Server/TCP/TLS.php new file mode 100644 index 0000000..91ea367 --- /dev/null +++ b/src/Server/TCP/TLS.php @@ -0,0 +1,142 @@ +certPath)) { + throw new \RuntimeException("TLS certificate file not readable: {$this->certPath}"); + } + + if (!is_readable($this->keyPath)) { + throw new \RuntimeException("TLS private key file not readable: {$this->keyPath}"); + } + + if ($this->requireClientCert && $this->caPath === '') { + throw new \RuntimeException('CA certificate path is required when client certificate verification is enabled'); + } + + if ($this->caPath !== '' && !is_readable($this->caPath)) { + throw new \RuntimeException("TLS CA certificate file not readable: {$this->caPath}"); + } + } + + /** + * Check if this is an mTLS configuration (requires client certificates) + */ + public function isMutualTLS(): bool + { + return $this->requireClientCert && $this->caPath !== ''; + } + + /** + * Detect whether a raw data packet is a PostgreSQL SSLRequest message + * + * The SSLRequest is exactly 8 bytes: + * - Int32(8): length + * - Int32(80877103): SSL request code (0x04D2162F) + */ + public static function isPostgreSQLSSLRequest(string $data): bool + { + return strlen($data) === 8 && $data === self::PG_SSL_REQUEST; + } + + /** + * Detect whether a raw data packet is a MySQL SSL handshake request + * + * After receiving the server greeting with SSL capability flag, + * the client sends an SSL request packet. This is identified by: + * - Packet length >= 4 bytes (header) + * - Capability flags in bytes 4-7 include CLIENT_SSL (0x0800) + * - Sequence ID = 1 (byte 3) + */ + public static function isMySQLSSLRequest(string $data): bool + { + if (strlen($data) < 36) { + return false; + } + + // Sequence ID should be 1 (client response to server greeting) + if (ord($data[3]) !== 1) { + return false; + } + + // Read capability flags (little-endian uint16 at offset 4) + $capLow = ord($data[4]) | (ord($data[5]) << 8); + + return ($capLow & self::MYSQL_CLIENT_SSL_FLAG) !== 0; + } +} diff --git a/src/Server/TCP/TlsContext.php b/src/Server/TCP/TlsContext.php new file mode 100644 index 0000000..bdab218 --- /dev/null +++ b/src/Server/TCP/TlsContext.php @@ -0,0 +1,118 @@ +set($ctx->toSwooleConfig()); + * + * // For stream_context_create + * $streamCtx = $ctx->toStreamContext(); + * ``` + */ +class TlsContext +{ + public function __construct( + protected TLS $tls, + ) { + } + + /** + * Build Swoole server SSL configuration array + * + * Returns settings suitable for Swoole\Server::set() when the server + * is created with SWOOLE_SOCK_TCP | SWOOLE_SSL socket type. + * + * @return array + */ + public function toSwooleConfig(): array + { + $config = [ + 'ssl_cert_file' => $this->tls->certPath, + 'ssl_key_file' => $this->tls->keyPath, + 'ssl_protocols' => $this->tls->minProtocol, + 'ssl_ciphers' => $this->tls->ciphers, + 'ssl_allow_self_signed' => false, + ]; + + if ($this->tls->caPath !== '') { + $config['ssl_client_cert_file'] = $this->tls->caPath; + } + + if ($this->tls->requireClientCert) { + $config['ssl_verify_peer'] = true; + $config['ssl_verify_depth'] = 10; + } else { + $config['ssl_verify_peer'] = false; + } + + return $config; + } + + /** + * Build a PHP stream context resource for SSL connections + * + * Returns a context resource that can be used with stream_socket_server, + * stream_socket_enable_crypto, and similar stream functions. + * + * @return resource + */ + public function toStreamContext(): mixed + { + $sslOptions = [ + 'local_cert' => $this->tls->certPath, + 'local_pk' => $this->tls->keyPath, + 'disable_compression' => true, + 'allow_self_signed' => false, + 'ciphers' => $this->tls->ciphers, + 'crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_2_SERVER | STREAM_CRYPTO_METHOD_TLSv1_3_SERVER, + ]; + + if ($this->tls->caPath !== '') { + $sslOptions['cafile'] = $this->tls->caPath; + } + + if ($this->tls->requireClientCert) { + $sslOptions['verify_peer'] = true; + $sslOptions['verify_peer_name'] = false; + $sslOptions['verify_depth'] = 10; + } else { + $sslOptions['verify_peer'] = false; + $sslOptions['verify_peer_name'] = false; + } + + return stream_context_create(['ssl' => $sslOptions]); + } + + /** + * Get the Swoole socket type flag for TLS-enabled TCP + * + * Combines SWOOLE_SOCK_TCP with SWOOLE_SSL when TLS is configured. + */ + public function getSocketType(): int + { + return SWOOLE_SOCK_TCP | SWOOLE_SSL; + } + + /** + * Get the underlying TLS configuration + */ + public function getTls(): TLS + { + return $this->tls; + } +} diff --git a/src/Smtp/SmtpConnectionManager.php b/src/Smtp/SmtpConnectionManager.php deleted file mode 100644 index 1d915c7..0000000 --- a/src/Smtp/SmtpConnectionManager.php +++ /dev/null @@ -1,46 +0,0 @@ -dbPool->get(); - - try { - $doc = $db->findOne('smtpServers', [ - Query::equal('domain', [$resourceId]) - ]); - - if (empty($doc)) { - throw new \Exception("SMTP server not found for domain: {$resourceId}"); - } - - return new Resource( - id: $doc->getId(), - containerId: $doc->getAttribute('containerId'), - type: 'smtp-server', - tier: $doc->getAttribute('tier', 'shared'), - region: $doc->getAttribute('region') - ); - } finally { - $this->dbPool->put($db); - } - } - - protected function getProtocol(): string - { - return 'smtp'; - } -} diff --git a/src/Tcp/TcpConnectionManager.php b/src/Tcp/TcpConnectionManager.php deleted file mode 100644 index 2ee8317..0000000 --- a/src/Tcp/TcpConnectionManager.php +++ /dev/null @@ -1,166 +0,0 @@ -port = $port; - } - - protected function identifyResource(string $resourceId): Resource - { - // For TCP: resourceId is database ID extracted from SNI/hostname - $db = $this->dbPool->get(); - - try { - $doc = $db->findOne('databases', [ - Query::equal('hostname', [$resourceId]) - ]); - - if (empty($doc)) { - throw new \Exception("Database not found for hostname: {$resourceId}"); - } - - return new Resource( - id: $doc->getId(), - containerId: $doc->getAttribute('containerId'), - type: 'database', - tier: $doc->getAttribute('tier', 'shared'), - region: $doc->getAttribute('region') - ); - } finally { - $this->dbPool->put($db); - } - } - - protected function getProtocol(): string - { - return $this->port === 5432 ? 'postgresql' : 'mysql'; - } - - /** - * Parse database ID from TCP packet - * - * For PostgreSQL: Extract from SNI or startup message - * For MySQL: Extract from initial handshake - */ - public function parseDatabaseId(string $data, int $fd): string - { - if ($this->port === 5432) { - return $this->parsePostgreSQLDatabaseId($data); - } else { - return $this->parseMySQLDatabaseId($data); - } - } - - /** - * Parse PostgreSQL database ID from startup message - * - * Format: "database\0db-abc123\0" - */ - protected function parsePostgreSQLDatabaseId(string $data): string - { - // PostgreSQL startup message contains database name - if (preg_match('/database\x00([^\x00]+)\x00/', $data, $matches)) { - $dbName = $matches[1]; - - // Extract database ID from format: db-{id}.appwrite.network - if (preg_match('/^db-([a-z0-9]+)/', $dbName, $idMatches)) { - return $idMatches[1]; - } - } - - throw new \Exception('Invalid PostgreSQL database name'); - } - - /** - * Parse MySQL database ID from connection - * - * For MySQL, we typically get the database from subsequent COM_INIT_DB packet - */ - protected function parseMySQLDatabaseId(string $data): string - { - // MySQL COM_INIT_DB packet (0x02) - if (strlen($data) > 5 && ord($data[4]) === 0x02) { - $dbName = substr($data, 5); - - // Extract database ID from format: db-{id} - if (preg_match('/^db-([a-z0-9]+)/', $dbName, $matches)) { - return $matches[1]; - } - } - - throw new \Exception('Invalid MySQL database name'); - } - - /** - * Get or create backend connection - * - * Performance: Reuses connections for same database - */ - public function getBackendConnection(string $databaseId, int $clientFd): int - { - // Check if we already have a connection for this database - $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; - - if (isset($this->backendConnections[$cacheKey])) { - return $this->backendConnections[$cacheKey]; - } - - // Get backend endpoint - $result = $this->handleConnection($databaseId); - - // Create new TCP connection to backend - [$host, $port] = explode(':', $result->endpoint . ':' . $this->port); - $port = (int)$port; - - $client = new Client(SWOOLE_SOCK_TCP); - - if (!$client->connect($host, $port, $this->coldStartTimeout / 1000)) { - throw new \Exception("Failed to connect to backend: {$host}:{$port}"); - } - - // Store backend file descriptor - $backendFd = $client->sock; - $this->backendConnections[$cacheKey] = $backendFd; - - return $backendFd; - } - - /** - * Close backend connection - */ - public function closeBackendConnection(string $databaseId, int $clientFd): void - { - $cacheKey = "backend:connection:{$databaseId}:{$clientFd}"; - - if (isset($this->backendConnections[$cacheKey])) { - unset($this->backendConnections[$cacheKey]); - } - } -} diff --git a/src/Tcp/TcpServer.php b/src/Tcp/TcpServer.php deleted file mode 100644 index db1e368..0000000 --- a/src/Tcp/TcpServer.php +++ /dev/null @@ -1,246 +0,0 @@ -ports = $ports; - $this->config = array_merge([ - 'host' => $host, - 'workers' => $workers, - 'max_connections' => 100000, - 'max_coroutine' => 100000, - 'socket_buffer_size' => 8 * 1024 * 1024, // 8MB for database traffic - 'buffer_output_size' => 8 * 1024 * 1024, - 'enable_coroutine' => true, - 'max_wait_time' => 60, - ], $config); - - // Create main server on first port - $this->server = new Server($host, $ports[0], SWOOLE_PROCESS, SWOOLE_SOCK_TCP); - - // Add listeners for additional ports - for ($i = 1; $i < count($ports); $i++) { - $this->server->addlistener($host, $ports[$i], SWOOLE_SOCK_TCP); - } - - $this->configure(); - } - - protected function configure(): void - { - $this->server->set([ - 'worker_num' => $this->config['workers'], - 'max_connection' => $this->config['max_connections'], - 'max_coroutine' => $this->config['max_coroutine'], - 'socket_buffer_size' => $this->config['socket_buffer_size'], - 'buffer_output_size' => $this->config['buffer_output_size'], - 'enable_coroutine' => $this->config['enable_coroutine'], - 'max_wait_time' => $this->config['max_wait_time'], - - // TCP performance tuning - 'open_tcp_nodelay' => true, - 'tcp_fastopen' => true, - 'open_cpu_affinity' => true, - 'tcp_defer_accept' => 5, - 'open_tcp_keepalive' => true, - 'tcp_keepidle' => 4, - 'tcp_keepinterval' => 5, - 'tcp_keepcount' => 5, - - // Package settings for database protocols - 'open_length_check' => false, // Let database handle framing - 'package_max_length' => 8 * 1024 * 1024, // 8MB max query - - // Enable stats - 'task_enable_coroutine' => true, - ]); - - $this->server->on('start', [$this, 'onStart']); - $this->server->on('workerStart', [$this, 'onWorkerStart']); - $this->server->on('connect', [$this, 'onConnect']); - $this->server->on('receive', [$this, 'onReceive']); - $this->server->on('close', [$this, 'onClose']); - } - - public function onStart(Server $server): void - { - echo "TCP Proxy Server started at {$this->config['host']}\n"; - echo "Ports: " . implode(', ', $this->ports) . "\n"; - echo "Workers: {$this->config['workers']}\n"; - echo "Max connections: {$this->config['max_connections']}\n"; - } - - public function onWorkerStart(Server $server, int $workerId): void - { - // Initialize connection manager per worker per port - foreach ($this->ports as $port) { - $this->managers[$port] = new TcpConnectionManager( - cache: $this->initCache(), - dbPool: $this->initDbPool(), - computeApiUrl: $this->config['compute_api_url'] ?? 'http://appwrite-api/v1/compute', - computeApiKey: $this->config['compute_api_key'] ?? '', - coldStartTimeout: $this->config['cold_start_timeout'] ?? 30000, - healthCheckInterval: $this->config['health_check_interval'] ?? 100, - port: $port - ); - } - - echo "Worker #{$workerId} started\n"; - } - - /** - * Handle new TCP connection - */ - public function onConnect(Server $server, int $fd, int $reactorId): void - { - $info = $server->getClientInfo($fd); - $port = $info['server_port'] ?? 0; - - echo "Client #{$fd} connected to port {$port}\n"; - } - - /** - * Main receive handler - FAST AS FUCK - * - * Performance: <1ms overhead for proxying - */ - public function onReceive(Server $server, int $fd, int $reactorId, string $data): void - { - $startTime = microtime(true); - - try { - $info = $server->getClientInfo($fd); - $port = $info['server_port'] ?? 0; - - $manager = $this->managers[$port] ?? null; - if (!$manager) { - throw new \Exception("No manager for port {$port}"); - } - - // Parse database ID from initial packet (SNI or first query) - $databaseId = $manager->parseDatabaseId($data, $fd); - - // Get or create backend connection - $backendFd = $manager->getBackendConnection($databaseId, $fd); - - // Forward data to backend using zero-copy where possible - $this->forwardToBackend($server, $fd, $backendFd, $data); - - // Start bidirectional forwarding in coroutine - if (!isset($server->connections[$fd]['forwarding'])) { - $server->connections[$fd]['forwarding'] = true; - $this->startForwarding($server, $fd, $backendFd); - } - - } catch (\Exception $e) { - echo "Error handling data from #{$fd}: {$e->getMessage()}\n"; - $server->close($fd); - } - } - - /** - * Bidirectional forwarding loop - ZERO-COPY - * - * Performance: 10GB/s+ throughput - */ - protected function startForwarding(Server $server, int $clientFd, int $backendFd): void - { - Coroutine::create(function () use ($server, $clientFd, $backendFd) { - // Forward client -> backend - while ($server->exist($clientFd) && $server->exist($backendFd)) { - $data = $server->recv($clientFd, 65536, 0.1); - - if ($data === false || $data === '') { - break; - } - - $server->send($backendFd, $data); - } - }); - - Coroutine::create(function () use ($server, $clientFd, $backendFd) { - // Forward backend -> client - while ($server->exist($clientFd) && $server->exist($backendFd)) { - $data = $server->recv($backendFd, 65536, 0.1); - - if ($data === false || $data === '') { - break; - } - - $server->send($clientFd, $data); - } - }); - } - - protected function forwardToBackend(Server $server, int $clientFd, int $backendFd, string $data): void - { - $server->send($backendFd, $data); - } - - public function onClose(Server $server, int $fd, int $reactorId): void - { - echo "Client #{$fd} disconnected\n"; - - // Close backend connection if exists - if (isset($server->connections[$fd]['backend_fd'])) { - $server->close($server->connections[$fd]['backend_fd']); - } - } - - protected function initCache(): \Utopia\Cache\Cache - { - $redis = new \Redis(); - $redis->connect($this->config['redis_host'] ?? '127.0.0.1', $this->config['redis_port'] ?? 6379); - - $adapter = new \Utopia\Cache\Adapter\Redis($redis); - return new \Utopia\Cache\Cache($adapter); - } - - protected function initDbPool(): \Utopia\Pools\Group - { - // Connection pool implementation - return new \Utopia\Pools\Group(); - } - - public function start(): void - { - $this->server->start(); - } - - public function getStats(): array - { - $managerStats = []; - foreach ($this->managers as $port => $manager) { - $managerStats[$port] = $manager->getStats(); - } - - return [ - 'connections' => $this->server->stats()['connection_num'] ?? 0, - 'workers' => $this->server->stats()['worker_num'] ?? 0, - 'coroutines' => Coroutine::stats()['coroutine_num'] ?? 0, - 'managers' => $managerStats, - ]; - } -} diff --git a/tests/AdapterActionsTest.php b/tests/AdapterActionsTest.php new file mode 100644 index 0000000..31cce81 --- /dev/null +++ b/tests/AdapterActionsTest.php @@ -0,0 +1,125 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testResolverIsAssignedToAdapters(): void + { + $http = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + $tcp = new TCPAdapter($this->resolver, port: 5432); + $smtp = new Adapter($this->resolver, name: 'SMTP', protocol: Protocol::SMTP); + + $this->assertSame($this->resolver, $http->resolver); + $this->assertSame($this->resolver, $tcp->resolver); + $this->assertSame($this->resolver, $smtp->resolver); + } + + public function testResolveRoutesAndReturnsEndpoint(): void + { + $this->resolver->setEndpoint('127.0.0.1:8080'); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $result = $adapter->route('api.example.com'); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame(Protocol::HTTP, $result->protocol); + } + + public function testNotifyConnectDelegatesToResolver(): void + { + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + $adapter->notifyConnect('resource-123', ['extra' => 'data']); + + $connects = $this->resolver->getConnects(); + $this->assertCount(1, $connects); + $this->assertSame('resource-123', $connects[0]['resourceId']); + $this->assertSame(['extra' => 'data'], $connects[0]['metadata']); + } + + public function testNotifyCloseDelegatesToResolver(): void + { + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + $adapter->notifyClose('resource-123', ['extra' => 'data']); + + $disconnects = $this->resolver->getDisconnects(); + $this->assertCount(1, $disconnects); + $this->assertSame('resource-123', $disconnects[0]['resourceId']); + $this->assertSame(['extra' => 'data'], $disconnects[0]['metadata']); + } + + public function testTrackActivityDelegatesToResolverWithThrottling(): void + { + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + $adapter->setActivityInterval(1); // 1 second throttle + + // First call should trigger activity tracking + $adapter->track('resource-123'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Immediate second call should be throttled + $adapter->track('resource-123'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Wait for throttle interval to pass + sleep(2); + + // Third call should trigger activity tracking + $adapter->track('resource-123'); + $this->assertCount(2, $this->resolver->getActivities()); + } + + public function testRoutingErrorThrowsException(): void + { + $this->resolver->setException(new ResolverException('No backend found')); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('No backend found'); + + $adapter->route('api.example.com'); + } + + public function testEmptyEndpointThrowsException(): void + { + $this->resolver->setEndpoint(''); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Resolver returned empty endpoint'); + + $adapter->route('api.example.com'); + } + + public function testSkipValidationAllowsPrivateIPs(): void + { + // 10.0.0.1 is a private IP that would normally be blocked + $this->resolver->setEndpoint('10.0.0.1:8080'); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + // Should not throw exception with validation disabled + $result = $adapter->route('api.example.com'); + $this->assertSame('10.0.0.1:8080', $result->endpoint); + } +} diff --git a/tests/AdapterByteTrackingTest.php b/tests/AdapterByteTrackingTest.php new file mode 100644 index 0000000..294b6b2 --- /dev/null +++ b/tests/AdapterByteTrackingTest.php @@ -0,0 +1,231 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testRecordBytesInitializesCounters(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + + // Verify via notifyClose which flushes byte counters + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(100, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(200, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesAccumulatesValues(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->recordBytes('resource-1', inbound: 50, outbound: 75); + $adapter->recordBytes('resource-1', inbound: 25, outbound: 25); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(175, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(300, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesDefaultsToZero(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1'); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(0, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(0, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesInboundOnly(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 500); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(500, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(0, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesOutboundOnly(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', outbound: 300); + + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(0, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(300, $disconnects[0]['metadata']['outboundBytes']); + } + + public function testRecordBytesTracksMultipleResources(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->recordBytes('resource-2', inbound: 300, outbound: 400); + + $adapter->notifyClose('resource-1'); + $adapter->notifyClose('resource-2'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(100, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(200, $disconnects[0]['metadata']['outboundBytes']); + $this->assertSame(300, $disconnects[1]['metadata']['inboundBytes']); + $this->assertSame(400, $disconnects[1]['metadata']['outboundBytes']); + } + + public function testNotifyCloseFlushesAndClearsCounters(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->notifyClose('resource-1'); + + // Second close should not include byte data + $adapter->notifyClose('resource-1'); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertArrayHasKey('inboundBytes', $disconnects[0]['metadata']); + $this->assertArrayNotHasKey('inboundBytes', $disconnects[1]['metadata']); + } + + public function testNotifyCloseWithoutByteRecordingOmitsByteMetadata(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->notifyClose('resource-1', ['reason' => 'timeout']); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertArrayNotHasKey('inboundBytes', $disconnects[0]['metadata']); + $this->assertSame('timeout', $disconnects[0]['metadata']['reason']); + } + + public function testNotifyCloseMergesByteDataWithExistingMetadata(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->notifyClose('resource-1', ['reason' => 'client_disconnect']); + $disconnects = $this->resolver->getDisconnects(); + + $this->assertSame(100, $disconnects[0]['metadata']['inboundBytes']); + $this->assertSame(200, $disconnects[0]['metadata']['outboundBytes']); + $this->assertSame('client_disconnect', $disconnects[0]['metadata']['reason']); + } + + public function testTrackFlushesAccumulatedBytes(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + $adapter->setActivityInterval(0); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->track('resource-1'); + + $activities = $this->resolver->getActivities(); + $this->assertCount(1, $activities); + $this->assertSame(100, $activities[0]['metadata']['inboundBytes']); + $this->assertSame(200, $activities[0]['metadata']['outboundBytes']); + } + + public function testTrackResetsCountersAfterFlush(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + $adapter->setActivityInterval(0); + + $adapter->recordBytes('resource-1', inbound: 100, outbound: 200); + $adapter->track('resource-1'); + + // Record more bytes and track again + $adapter->recordBytes('resource-1', inbound: 50, outbound: 25); + + // Need to wait for throttle to pass (interval is 0 but time() is same second) + // Force a new second + sleep(1); + $adapter->track('resource-1'); + + $activities = $this->resolver->getActivities(); + $this->assertCount(2, $activities); + $this->assertSame(50, $activities[1]['metadata']['inboundBytes']); + $this->assertSame(25, $activities[1]['metadata']['outboundBytes']); + } + + public function testTrackWithoutBytesOmitsByteMetadata(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + $adapter->setActivityInterval(0); + + $adapter->track('resource-1', ['type' => 'query']); + + $activities = $this->resolver->getActivities(); + $this->assertCount(1, $activities); + $this->assertArrayNotHasKey('inboundBytes', $activities[0]['metadata']); + $this->assertSame('query', $activities[0]['metadata']['type']); + } + + public function testNotifyCloseClearsActivityTimestamp(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + $adapter->setActivityInterval(9999); + + // Track once to set the timestamp + $adapter->track('resource-1'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Normally this would be throttled + $adapter->track('resource-1'); + $this->assertCount(1, $this->resolver->getActivities()); + + // Close clears the timestamp + $adapter->notifyClose('resource-1'); + + // Now tracking should work again immediately + $adapter->track('resource-1'); + $this->assertCount(2, $this->resolver->getActivities()); + } + + public function testSetActivityIntervalReturnsSelf(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $result = $adapter->setActivityInterval(60); + $this->assertSame($adapter, $result); + } + + public function testSetSkipValidationReturnsSelf(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::TCP); + + $result = $adapter->setSkipValidation(true); + $this->assertSame($adapter, $result); + } +} diff --git a/tests/AdapterMetadataTest.php b/tests/AdapterMetadataTest.php new file mode 100644 index 0000000..65d9f45 --- /dev/null +++ b/tests/AdapterMetadataTest.php @@ -0,0 +1,50 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testHttpAdapterMetadata(): void + { + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP, description: 'HTTP proxy adapter'); + + $this->assertSame('HTTP', $adapter->getName()); + $this->assertSame(Protocol::HTTP, $adapter->getProtocol()); + $this->assertSame('HTTP proxy adapter', $adapter->getDescription()); + } + + public function testSmtpAdapterMetadata(): void + { + $adapter = new Adapter($this->resolver, name: 'SMTP', protocol: Protocol::SMTP, description: 'SMTP proxy adapter'); + + $this->assertSame('SMTP', $adapter->getName()); + $this->assertSame(Protocol::SMTP, $adapter->getProtocol()); + $this->assertSame('SMTP proxy adapter', $adapter->getDescription()); + } + + public function testTcpAdapterMetadata(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->assertSame('TCP', $adapter->getName()); + $this->assertSame(Protocol::PostgreSQL, $adapter->getProtocol()); + $this->assertSame('TCP proxy adapter for database connections (PostgreSQL, MySQL, MongoDB)', $adapter->getDescription()); + $this->assertSame(5432, $adapter->port); + } +} diff --git a/tests/AdapterStatsTest.php b/tests/AdapterStatsTest.php new file mode 100644 index 0000000..31e2914 --- /dev/null +++ b/tests/AdapterStatsTest.php @@ -0,0 +1,82 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testCacheHitUpdatesStats(): void + { + $this->resolver->setEndpoint('127.0.0.1:8080'); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('api.example.com'); + $second = $adapter->route('api.example.com'); + + $this->assertFalse($first->metadata['cached']); + $this->assertTrue($second->metadata['cached']); + + $stats = $adapter->getStats(); + $this->assertSame(2, $stats['connections']); + $this->assertSame(1, $stats['cacheHits']); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertSame(50.0, $stats['cacheHitRate']); + $this->assertSame(0, $stats['routingErrors']); + $this->assertSame(1, $stats['routingTableSize']); + $this->assertGreaterThan(0, $stats['routingTableMemory']); + } + + public function testRoutingErrorIncrementsStats(): void + { + $this->resolver->setException(new ResolverException('No backend')); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + + try { + $adapter->route('api.example.com'); + $this->fail('Expected routing error was not thrown.'); + } catch (ResolverException $e) { + $this->assertSame('No backend', $e->getMessage()); + } + + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['routingErrors']); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame(0.0, $stats['cacheHitRate']); + } + + public function testResolverStatsAreIncludedInAdapterStats(): void + { + $this->resolver->setEndpoint('127.0.0.1:8080'); + $adapter = new Adapter($this->resolver, name: 'HTTP', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $adapter->route('api.example.com'); + + $stats = $adapter->getStats(); + $this->assertArrayHasKey('resolver', $stats); + $this->assertIsArray($stats['resolver']); + $this->assertSame('mock', $stats['resolver']['resolver']); + } +} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php new file mode 100644 index 0000000..153a2db --- /dev/null +++ b/tests/ConfigTest.php @@ -0,0 +1,234 @@ +markTestSkipped('ext-swoole is required to run Config tests.'); + } + } + + public function testDefaultHost(): void + { + $config = new Config(); + $this->assertSame('0.0.0.0', $config->host); + } + + public function testDefaultPorts(): void + { + $config = new Config(); + $this->assertSame([5432, 3306, 27017], $config->ports); + } + + public function testDefaultWorkers(): void + { + $config = new Config(); + $this->assertSame(16, $config->workers); + } + + public function testDefaultMaxConnections(): void + { + $config = new Config(); + $this->assertSame(200_000, $config->maxConnections); + } + + public function testDefaultMaxCoroutine(): void + { + $config = new Config(); + $this->assertSame(200_000, $config->maxCoroutine); + } + + public function testDefaultBufferSizes(): void + { + $config = new Config(); + $this->assertSame(16 * 1024 * 1024, $config->socketBufferSize); + $this->assertSame(16 * 1024 * 1024, $config->bufferOutputSize); + } + + public function testDefaultReactorNumIsCpuBased(): void + { + $config = new Config(); + $this->assertSame(swoole_cpu_num() * 2, $config->reactorNum); + } + + public function testDefaultDispatchMode(): void + { + $config = new Config(); + $this->assertSame(2, $config->dispatchMode); + } + + public function testDefaultEnableReusePort(): void + { + $config = new Config(); + $this->assertTrue($config->enableReusePort); + } + + public function testDefaultBacklog(): void + { + $config = new Config(); + $this->assertSame(65535, $config->backlog); + } + + public function testDefaultPackageMaxLength(): void + { + $config = new Config(); + $this->assertSame(32 * 1024 * 1024, $config->packageMaxLength); + } + + public function testDefaultTcpKeepaliveSettings(): void + { + $config = new Config(); + $this->assertSame(30, $config->tcpKeepidle); + $this->assertSame(10, $config->tcpKeepinterval); + $this->assertSame(3, $config->tcpKeepcount); + } + + public function testDefaultEnableCoroutine(): void + { + $config = new Config(); + $this->assertTrue($config->enableCoroutine); + } + + public function testDefaultMaxWaitTime(): void + { + $config = new Config(); + $this->assertSame(60, $config->maxWaitTime); + } + + public function testDefaultLogLevel(): void + { + $config = new Config(); + $this->assertSame(SWOOLE_LOG_ERROR, $config->logLevel); + } + + public function testDefaultLogConnections(): void + { + $config = new Config(); + $this->assertFalse($config->logConnections); + } + + public function testDefaultRecvBufferSize(): void + { + $config = new Config(); + $this->assertSame(131072, $config->recvBufferSize); + } + + public function testDefaultBackendConnectTimeout(): void + { + $config = new Config(); + $this->assertSame(5.0, $config->backendConnectTimeout); + } + + public function testDefaultSkipValidation(): void + { + $config = new Config(); + $this->assertFalse($config->skipValidation); + } + + public function testDefaultReadWriteSplit(): void + { + $config = new Config(); + $this->assertFalse($config->readWriteSplit); + } + + public function testDefaultTlsIsNull(): void + { + $config = new Config(); + $this->assertNull($config->tls); + } + + public function testCustomReactorNum(): void + { + $config = new Config(reactorNum: 4); + $this->assertSame(4, $config->reactorNum); + } + + public function testCustomPorts(): void + { + $config = new Config(ports: [5432]); + $this->assertSame([5432], $config->ports); + } + + public function testCustomHost(): void + { + $config = new Config(host: '127.0.0.1'); + $this->assertSame('127.0.0.1', $config->host); + } + + public function testCustomWorkers(): void + { + $config = new Config(workers: 4); + $this->assertSame(4, $config->workers); + } + + public function testCustomBackendConnectTimeout(): void + { + $config = new Config(backendConnectTimeout: 10.5); + $this->assertSame(10.5, $config->backendConnectTimeout); + } + + public function testCustomSkipValidation(): void + { + $config = new Config(skipValidation: true); + $this->assertTrue($config->skipValidation); + } + + public function testCustomReadWriteSplit(): void + { + $config = new Config(readWriteSplit: true); + $this->assertTrue($config->readWriteSplit); + } + + public function testCustomLogConnections(): void + { + $config = new Config(logConnections: true); + $this->assertTrue($config->logConnections); + } + + public function testIsTlsEnabledFalseByDefault(): void + { + $config = new Config(); + $this->assertFalse($config->isTlsEnabled()); + } + + public function testIsTlsEnabledTrueWhenConfigured(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $config = new Config(tls: $tls); + $this->assertTrue($config->isTlsEnabled()); + } + + public function testGetTlsContextNullByDefault(): void + { + $config = new Config(); + $this->assertNull($config->getTlsContext()); + } + + public function testGetTlsContextReturnsInstanceWhenConfigured(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $config = new Config(tls: $tls); + + $ctx = $config->getTlsContext(); + $this->assertInstanceOf(TlsContext::class, $ctx); + $this->assertSame($tls, $ctx->getTls()); + } + + public function testGetTlsContextReturnsNewInstanceEachCall(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $config = new Config(tls: $tls); + + $ctx1 = $config->getTlsContext(); + $ctx2 = $config->getTlsContext(); + $this->assertNotSame($ctx1, $ctx2); + } +} diff --git a/tests/ConnectionResultExtendedTest.php b/tests/ConnectionResultExtendedTest.php new file mode 100644 index 0000000..e3ca78f --- /dev/null +++ b/tests/ConnectionResultExtendedTest.php @@ -0,0 +1,92 @@ +assertSame($protocol, $result->protocol); + } + } + + public function testDefaultEmptyMetadata(): void + { + $result = new ConnectionResult( + endpoint: '127.0.0.1:8080', + protocol: Protocol::HTTP, + ); + + $this->assertSame([], $result->metadata); + } + + public function testMetadataWithMultipleTypes(): void + { + $result = new ConnectionResult( + endpoint: '127.0.0.1:8080', + protocol: Protocol::HTTP, + metadata: [ + 'cached' => true, + 'latency' => 1.5, + 'count' => 42, + 'tags' => ['fast', 'reliable'], + 'config' => ['timeout' => 30], + ] + ); + + $this->assertTrue($result->metadata['cached']); + $this->assertSame(1.5, $result->metadata['latency']); + $this->assertSame(42, $result->metadata['count']); + $this->assertSame(['fast', 'reliable'], $result->metadata['tags']); + $this->assertSame(['timeout' => 30], $result->metadata['config']); + } + + public function testEndpointWithHostOnly(): void + { + $result = new ConnectionResult( + endpoint: 'db.example.com', + protocol: Protocol::PostgreSQL, + ); + + $this->assertSame('db.example.com', $result->endpoint); + } + + public function testEndpointWithHostAndPort(): void + { + $result = new ConnectionResult( + endpoint: 'db.example.com:5432', + protocol: Protocol::PostgreSQL, + ); + + $this->assertSame('db.example.com:5432', $result->endpoint); + } + + public function testEndpointWithIpAddress(): void + { + $result = new ConnectionResult( + endpoint: '192.168.1.100:3306', + protocol: Protocol::MySQL, + ); + + $this->assertSame('192.168.1.100:3306', $result->endpoint); + } +} diff --git a/tests/ConnectionResultTest.php b/tests/ConnectionResultTest.php new file mode 100644 index 0000000..8b8ca80 --- /dev/null +++ b/tests/ConnectionResultTest.php @@ -0,0 +1,23 @@ + false] + ); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame(Protocol::HTTP, $result->protocol); + $this->assertSame(['cached' => false], $result->metadata); + } +} diff --git a/tests/EndpointValidationTest.php b/tests/EndpointValidationTest.php new file mode 100644 index 0000000..24ca5f9 --- /dev/null +++ b/tests/EndpointValidationTest.php @@ -0,0 +1,261 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + private function createAdapter(): Adapter + { + return new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + } + + public function testRejectsEndpointWithMultipleColons(): void + { + $this->resolver->setEndpoint('host:port:extra'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid endpoint format'); + + $adapter->route('test'); + } + + public function testRejectsPortAbove65535(): void + { + $this->resolver->setEndpoint('example.com:70000'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid port number'); + + $adapter->route('test'); + } + + public function testRejectsPortWayAboveLimit(): void + { + $this->resolver->setEndpoint('example.com:999999'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Invalid port number'); + + $adapter->route('test'); + } + + public function testRejects10Network(): void + { + $this->resolver->setEndpoint('10.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects10NetworkHighEnd(): void + { + $this->resolver->setEndpoint('10.255.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects172Network(): void + { + $this->resolver->setEndpoint('172.16.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects172NetworkHighEnd(): void + { + $this->resolver->setEndpoint('172.31.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejects192168Network(): void + { + $this->resolver->setEndpoint('192.168.1.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsLoopbackIp(): void + { + $this->resolver->setEndpoint('127.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsLoopbackHighEnd(): void + { + $this->resolver->setEndpoint('127.255.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsLinkLocal(): void + { + $this->resolver->setEndpoint('169.254.1.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsMulticast(): void + { + $this->resolver->setEndpoint('224.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsMulticastHighEnd(): void + { + $this->resolver->setEndpoint('239.255.255.255:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsReservedRange240(): void + { + $this->resolver->setEndpoint('240.0.0.1:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testRejectsZeroNetwork(): void + { + $this->resolver->setEndpoint('0.0.0.0:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->route('test'); + } + + public function testAcceptsPublicIp(): void + { + // 8.8.8.8 is Google's public DNS + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8:80', $result->endpoint); + } + + public function testAcceptsPublicIpWithoutPort(): void + { + $this->resolver->setEndpoint('8.8.8.8'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8', $result->endpoint); + } + + public function testSkipValidationAllowsPrivateIps(): void + { + $this->resolver->setEndpoint('10.0.0.1:80'); + $adapter = $this->createAdapter(); + $adapter->setSkipValidation(true); + + $result = $adapter->route('test'); + $this->assertSame('10.0.0.1:80', $result->endpoint); + } + + public function testSkipValidationAllowsLoopback(): void + { + $this->resolver->setEndpoint('127.0.0.1:80'); + $adapter = $this->createAdapter(); + $adapter->setSkipValidation(true); + + $result = $adapter->route('test'); + $this->assertSame('127.0.0.1:80', $result->endpoint); + } + + public function testRejectsUnresolvableHostname(): void + { + $this->resolver->setEndpoint('this-hostname-definitely-does-not-exist-12345.invalid:80'); + $adapter = $this->createAdapter(); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('Cannot resolve hostname'); + + $adapter->route('test'); + } + + public function testAcceptsPort65535(): void + { + $this->resolver->setEndpoint('8.8.8.8:65535'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8:65535', $result->endpoint); + } + + public function testAcceptsPortZeroImplicit(): void + { + // No port specified resolves to 0 which is <= 65535 + $this->resolver->setEndpoint('8.8.8.8'); + $adapter = $this->createAdapter(); + + $result = $adapter->route('test'); + $this->assertSame('8.8.8.8', $result->endpoint); + } +} diff --git a/tests/Integration/EdgeIntegrationTest.php b/tests/Integration/EdgeIntegrationTest.php new file mode 100644 index 0000000..775835e --- /dev/null +++ b/tests/Integration/EdgeIntegrationTest.php @@ -0,0 +1,931 @@ +markTestSkipped('ext-swoole is required to run integration tests.'); + } + } + + /** + * @group integration + */ + public function testEdgeResolverResolvesDatabaseIdToEndpoint(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('abc123', [ + 'host' => '10.0.1.50', + 'port' => 5432, + 'username' => 'appwrite_user', + 'password' => 'secret_password', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + $result = $adapter->route('abc123'); + + $this->assertInstanceOf(ConnectionResult::class, $result); + $this->assertSame('10.0.1.50:5432', $result->endpoint); + $this->assertSame(Protocol::PostgreSQL, $result->protocol); + $this->assertSame('abc123', $result->metadata['resourceId']); + $this->assertSame('appwrite_user', $result->metadata['username']); + $this->assertFalse($result->metadata['cached']); + } + + /** + * @group integration + */ + public function testEdgeResolverReturnsNotFoundForUnknownDatabase(): void + { + $resolver = new EdgeMockResolver(); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(ResolverException::NOT_FOUND); + + $adapter->route('nonexistent'); + } + + /** + * @group integration + */ + public function testDatabaseIdExtractionFeedsIntoResolution(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('abc123', [ + 'host' => '10.0.1.50', + 'port' => 5432, + 'username' => 'user1', + 'password' => 'pass1', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + // Simulate PostgreSQL startup message containing "database\0db-abc123\0" + $startupData = "user\x00appwrite\x00database\x00db-abc123\x00"; + + $databaseId = $adapter->parseDatabaseId($startupData, 1); + $this->assertSame('abc123', $databaseId); + + $result = $adapter->route($databaseId); + $this->assertSame('10.0.1.50:5432', $result->endpoint); + } + + /** + * @group integration + */ + public function testMysqlDatabaseIdExtractionFeedsIntoResolution(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('xyz789', [ + 'host' => '10.0.2.30', + 'port' => 3306, + 'username' => 'mysql_user', + 'password' => 'mysql_pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 3306); + $adapter->setSkipValidation(true); + + // Simulate MySQL COM_INIT_DB packet + $mysqlData = "\x00\x00\x00\x00\x02db-xyz789"; + + $databaseId = $adapter->parseDatabaseId($mysqlData, 1); + $this->assertSame('xyz789', $databaseId); + + $result = $adapter->route($databaseId); + $this->assertSame('10.0.2.30:3306', $result->endpoint); + $this->assertSame(Protocol::MySQL, $result->protocol); + } + + /** + * @group integration + */ + public function testReadWriteSplitResolvesToDifferentEndpoints(): void + { + $resolver = new EdgeMockReadWriteResolver(); + $resolver->registerDatabase('rw123', [ + 'host' => '10.0.1.10', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + $resolver->registerReadReplica('rw123', [ + 'host' => '10.0.1.20', + 'port' => 5432, + 'username' => 'replica_user', + 'password' => 'replica_pass', + ]); + $resolver->registerWritePrimary('rw123', [ + 'host' => '10.0.1.10', + 'port' => 5432, + 'username' => 'primary_user', + 'password' => 'primary_pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $readResult = $adapter->routeQuery('rw123', QueryType::Read); + $this->assertSame('10.0.1.20:5432', $readResult->endpoint); + $this->assertSame('read', $readResult->metadata['route']); + + $writeResult = $adapter->routeQuery('rw123', QueryType::Write); + $this->assertSame('10.0.1.10:5432', $writeResult->endpoint); + $this->assertSame('write', $writeResult->metadata['route']); + + // Endpoints must be different + $this->assertNotSame($readResult->endpoint, $writeResult->endpoint); + } + + /** + * @group integration + */ + public function testReadWriteSplitDisabledUsesDefaultEndpoint(): void + { + $resolver = new EdgeMockReadWriteResolver(); + $resolver->registerDatabase('rw456', [ + 'host' => '10.0.1.99', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + $resolver->registerReadReplica('rw456', [ + 'host' => '10.0.1.20', + 'port' => 5432, + 'username' => 'replica_user', + 'password' => 'replica_pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + // read/write split is disabled by default + $adapter->setSkipValidation(true); + + $readResult = $adapter->routeQuery('rw456', QueryType::Read); + $this->assertSame('10.0.1.99:5432', $readResult->endpoint); + } + + /** + * @group integration + */ + public function testTransactionPinsReadsToPrimaryThroughFullFlow(): void + { + $resolver = new EdgeMockReadWriteResolver(); + $resolver->registerDatabase('txdb', [ + 'host' => '10.0.1.10', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + $resolver->registerReadReplica('txdb', [ + 'host' => '10.0.1.20', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + $resolver->registerWritePrimary('txdb', [ + 'host' => '10.0.1.10', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $clientFd = 42; + + // Before transaction: SELECT goes to read replica + $selectData = $this->buildPgQuery('SELECT * FROM users'); + $classification = $adapter->classifyQuery($selectData, $clientFd); + $this->assertSame(QueryType::Read, $classification); + + $result = $adapter->routeQuery('txdb', $classification); + $this->assertSame('10.0.1.20:5432', $result->endpoint); + + // BEGIN pins to primary + $beginData = $this->buildPgQuery('BEGIN'); + $classification = $adapter->classifyQuery($beginData, $clientFd); + $this->assertSame(QueryType::Write, $classification); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + + // During transaction: SELECT goes to primary (pinned) + $classification = $adapter->classifyQuery($selectData, $clientFd); + $this->assertSame(QueryType::Write, $classification); + + $result = $adapter->routeQuery('txdb', $classification); + $this->assertSame('10.0.1.10:5432', $result->endpoint); + + // COMMIT unpins + $commitData = $this->buildPgQuery('COMMIT'); + $adapter->classifyQuery($commitData, $clientFd); + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + + // After transaction: SELECT goes back to read replica + $classification = $adapter->classifyQuery($selectData, $clientFd); + $this->assertSame(QueryType::Read, $classification); + + $result = $adapter->routeQuery('txdb', $classification); + $this->assertSame('10.0.1.20:5432', $result->endpoint); + } + + /** + * @group integration + */ + public function testFailoverResolverUsesSecondaryOnPrimaryFailure(): void + { + $primaryResolver = new EdgeMockResolver(); + // Primary has no databases registered, so it will throw NOT_FOUND + + $secondaryResolver = new EdgeMockResolver(); + $secondaryResolver->registerDatabase('faildb', [ + 'host' => '10.0.2.50', + 'port' => 5432, + 'username' => 'failover_user', + 'password' => 'failover_pass', + ]); + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter($failoverResolver, port: 5432); + $adapter->setSkipValidation(true); + + $result = $adapter->route('faildb'); + + $this->assertSame('10.0.2.50:5432', $result->endpoint); + $this->assertTrue($failoverResolver->didFailover()); + } + + /** + * @group integration + */ + public function testFailoverResolverUsesPrimaryWhenAvailable(): void + { + $primaryResolver = new EdgeMockResolver(); + $primaryResolver->registerDatabase('okdb', [ + 'host' => '10.0.1.10', + 'port' => 5432, + 'username' => 'primary_user', + 'password' => 'primary_pass', + ]); + + $secondaryResolver = new EdgeMockResolver(); + $secondaryResolver->registerDatabase('okdb', [ + 'host' => '10.0.2.50', + 'port' => 5432, + 'username' => 'secondary_user', + 'password' => 'secondary_pass', + ]); + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter($failoverResolver, port: 5432); + $adapter->setSkipValidation(true); + + $result = $adapter->route('okdb'); + + $this->assertSame('10.0.1.10:5432', $result->endpoint); + $this->assertFalse($failoverResolver->didFailover()); + } + + /** + * @group integration + */ + public function testFailoverResolverPropagatesErrorWhenBothFail(): void + { + $primaryResolver = new EdgeMockResolver(); + $secondaryResolver = new EdgeMockResolver(); + // Neither has databases registered + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter($failoverResolver, port: 5432); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(ResolverException::NOT_FOUND); + + $adapter->route('nowhere'); + } + + /** + * @group integration + */ + public function testFailoverResolverHandlesUnavailablePrimary(): void + { + $primaryResolver = new EdgeMockResolver(); + $primaryResolver->setUnavailable(true); + + $secondaryResolver = new EdgeMockResolver(); + $secondaryResolver->registerDatabase('unavaildb', [ + 'host' => '10.0.3.10', + 'port' => 5432, + 'username' => 'backup_user', + 'password' => 'backup_pass', + ]); + + $failoverResolver = new EdgeFailoverResolver($primaryResolver, $secondaryResolver); + + $adapter = new TCPAdapter($failoverResolver, port: 5432); + $adapter->setSkipValidation(true); + + $result = $adapter->route('unavaildb'); + + $this->assertSame('10.0.3.10:5432', $result->endpoint); + $this->assertTrue($failoverResolver->didFailover()); + } + + /** + * @group integration + */ + public function testRoutingCacheReturnsCachedResultOnRepeat(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('cachedb', [ + 'host' => '10.0.4.10', + 'port' => 5432, + 'username' => 'cached_user', + 'password' => 'cached_pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + // Ensure we are at the start of a fresh second so both calls + // land within the same 1-second cache window + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('cachedb'); + $this->assertFalse($first->metadata['cached']); + + $second = $adapter->route('cachedb'); + $this->assertTrue($second->metadata['cached']); + + $this->assertSame($first->endpoint, $second->endpoint); + $this->assertSame(1, $resolver->getResolveCount()); + } + + /** + * @group integration + */ + public function testCacheInvalidationForcesReResolve(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('invaldb', [ + 'host' => '10.0.4.20', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + // Align to second boundary + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('invaldb'); + $this->assertFalse($first->metadata['cached']); + + // Invalidate the resolver cache + $resolver->purge('invaldb'); + + // Wait for the routing table cache to expire (1 second TTL) + sleep(2); + + $second = $adapter->route('invaldb'); + $this->assertFalse($second->metadata['cached']); + + // Should have resolved twice + $this->assertSame(2, $resolver->getResolveCount()); + } + + /** + * @group integration + */ + public function testDifferentDatabasesResolveIndependently(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('db1', [ + 'host' => '10.0.5.1', + 'port' => 5432, + 'username' => 'user1', + 'password' => 'pass1', + ]); + $resolver->registerDatabase('db2', [ + 'host' => '10.0.5.2', + 'port' => 5432, + 'username' => 'user2', + 'password' => 'pass2', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + $result1 = $adapter->route('db1'); + $result2 = $adapter->route('db2'); + + $this->assertSame('10.0.5.1:5432', $result1->endpoint); + $this->assertSame('10.0.5.2:5432', $result2->endpoint); + $this->assertNotSame($result1->endpoint, $result2->endpoint); + } + + /** + * @group integration + */ + public function testConcurrentResolutionOfMultipleDatabases(): void + { + $resolver = new EdgeMockResolver(); + $databaseCount = 20; + + for ($i = 1; $i <= $databaseCount; $i++) { + $resolver->registerDatabase("concurrent{$i}", [ + 'host' => "10.0.10.{$i}", + 'port' => 5432, + 'username' => "user_{$i}", + 'password' => "pass_{$i}", + ]); + } + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + $results = []; + for ($i = 1; $i <= $databaseCount; $i++) { + $results[$i] = $adapter->route("concurrent{$i}"); + } + + // Verify each database resolved to its correct endpoint + for ($i = 1; $i <= $databaseCount; $i++) { + $this->assertSame("10.0.10.{$i}:5432", $results[$i]->endpoint); + $this->assertSame(Protocol::PostgreSQL, $results[$i]->protocol); + } + + // All should have been cache misses (first resolution) + $stats = $adapter->getStats(); + $this->assertSame($databaseCount, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame($databaseCount, $stats['routingTableSize']); + } + + /** + * @group integration + */ + public function testConcurrentResolutionWithMixedSuccessAndFailure(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('gooddb1', [ + 'host' => '10.0.11.1', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + $resolver->registerDatabase('gooddb2', [ + 'host' => '10.0.11.2', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + // 'baddb' is intentionally not registered + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + $result1 = $adapter->route('gooddb1'); + $this->assertSame('10.0.11.1:5432', $result1->endpoint); + + $result2 = $adapter->route('gooddb2'); + $this->assertSame('10.0.11.2:5432', $result2->endpoint); + + try { + $adapter->route('baddb'); + $this->fail('Expected ResolverException for unknown database'); + } catch (ResolverException $e) { + $this->assertSame(ResolverException::NOT_FOUND, $e->getCode()); + } + + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['routingErrors']); + $this->assertSame(2, $stats['connections']); + } + + /** + * @group integration + */ + public function testConnectAndDisconnectLifecycleTracked(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('lifecycle1', [ + 'host' => '10.0.6.1', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + // Resolve the database + $adapter->route('lifecycle1'); + + // Notify connect + $adapter->notifyConnect('lifecycle1', ['clientFd' => 1]); + $this->assertCount(1, $resolver->getConnects()); + $this->assertSame('lifecycle1', $resolver->getConnects()[0]['resourceId']); + + // Track activity + $adapter->setActivityInterval(0); + $adapter->track('lifecycle1', ['query' => 'SELECT 1']); + $this->assertCount(1, $resolver->getActivities()); + + // Notify disconnect + $adapter->notifyClose('lifecycle1', ['clientFd' => 1]); + $this->assertCount(1, $resolver->getDisconnects()); + $this->assertSame('lifecycle1', $resolver->getDisconnects()[0]['resourceId']); + } + + /** + * @group integration + */ + public function testStatsAggregateAcrossOperations(): void + { + $resolver = new EdgeMockResolver(); + $resolver->registerDatabase('statsdb', [ + 'host' => '10.0.7.1', + 'port' => 5432, + 'username' => 'user', + 'password' => 'pass', + ]); + + $adapter = new TCPAdapter($resolver, port: 5432); + $adapter->setSkipValidation(true); + + // Align to second boundary + $start = time(); + while (time() === $start) { + usleep(1000); + } + + // Perform multiple operations + $adapter->route('statsdb'); // miss + $adapter->route('statsdb'); // hit + $adapter->route('statsdb'); // hit + + $adapter->notifyConnect('statsdb'); + $adapter->notifyClose('statsdb'); + + $stats = $adapter->getStats(); + + $this->assertSame('TCP', $stats['adapter']); + $this->assertSame('postgresql', $stats['protocol']); + $this->assertSame(3, $stats['connections']); + $this->assertSame(2, $stats['cacheHits']); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertGreaterThan(0.0, $stats['cacheHitRate']); + $this->assertSame(0, $stats['routingErrors']); + + /** @var array $resolverStats */ + $resolverStats = $stats['resolver']; + $this->assertSame(1, $resolverStats['connects']); + $this->assertSame(1, $resolverStats['disconnects']); + } + + private function buildPgQuery(string $sql): string + { + $body = $sql . "\x00"; + $length = \strlen($body) + 4; + + return 'Q' . \pack('N', $length) . $body; + } +} + +/** + * Simulates an Edge service resolver that resolves database IDs to backend + * endpoints via HTTP lookups. In production, the resolve() call would be an + * HTTP request to the Edge service. Here we simulate that with an in-memory + * registry. + */ +class EdgeMockResolver implements Resolver +{ + /** @var array */ + protected array $databases = []; + + /** @var array}> */ + protected array $connects = []; + + /** @var array}> */ + protected array $disconnects = []; + + /** @var array}> */ + protected array $activities = []; + + /** @var array */ + protected array $invalidations = []; + + protected int $resolveCount = 0; + + protected bool $unavailable = false; + + /** + * Register a database endpoint (simulates Edge service configuration) + * + * @param array{host: string, port: int, username: string, password: string} $config + */ + public function registerDatabase(string $databaseId, array $config): self + { + $this->databases[$databaseId] = $config; + + return $this; + } + + public function setUnavailable(bool $unavailable): self + { + $this->unavailable = $unavailable; + + return $this; + } + + public function resolve(string $resourceId): Result + { + if ($this->unavailable) { + throw new ResolverException( + "Edge service unavailable", + ResolverException::UNAVAILABLE, + ['resourceId' => $resourceId] + ); + } + + if (!isset($this->databases[$resourceId])) { + throw new ResolverException( + "Database not found: {$resourceId}", + ResolverException::NOT_FOUND, + ['resourceId' => $resourceId] + ); + } + + $this->resolveCount++; + $config = $this->databases[$resourceId]; + + return new Result( + endpoint: "{$config['host']}:{$config['port']}", + metadata: [ + 'resourceId' => $resourceId, + 'username' => $config['username'], + ] + ); + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function track(string $resourceId, array $metadata = []): void + { + $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function purge(string $resourceId): void + { + $this->invalidations[] = $resourceId; + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'resolver' => 'edge-mock', + 'connects' => count($this->connects), + 'disconnects' => count($this->disconnects), + 'activities' => count($this->activities), + 'resolveCount' => $this->resolveCount, + ]; + } + + public function getResolveCount(): int + { + return $this->resolveCount; + } + + /** @return array}> */ + public function getConnects(): array + { + return $this->connects; + } + + /** @return array}> */ + public function getDisconnects(): array + { + return $this->disconnects; + } + + /** @return array}> */ + public function getActivities(): array + { + return $this->activities; + } +} + +/** + * Extends EdgeMockResolver to support read/write split resolution. + * In production, the Edge service would return different endpoints for + * read replicas vs the primary writer. + */ +class EdgeMockReadWriteResolver extends EdgeMockResolver implements ReadWriteResolver +{ + /** @var array */ + protected array $readReplicas = []; + + /** @var array */ + protected array $writePrimaries = []; + + /** + * @param array{host: string, port: int, username: string, password: string} $config + */ + public function registerReadReplica(string $databaseId, array $config): self + { + $this->readReplicas[$databaseId] = $config; + + return $this; + } + + /** + * @param array{host: string, port: int, username: string, password: string} $config + */ + public function registerWritePrimary(string $databaseId, array $config): self + { + $this->writePrimaries[$databaseId] = $config; + + return $this; + } + + public function resolveRead(string $resourceId): Result + { + if (!isset($this->readReplicas[$resourceId])) { + throw new ResolverException( + "Read replica not found: {$resourceId}", + ResolverException::NOT_FOUND, + ['resourceId' => $resourceId, 'route' => 'read'] + ); + } + + $config = $this->readReplicas[$resourceId]; + + return new Result( + endpoint: "{$config['host']}:{$config['port']}", + metadata: [ + 'resourceId' => $resourceId, + 'username' => $config['username'], + 'route' => 'read', + ] + ); + } + + public function resolveWrite(string $resourceId): Result + { + if (!isset($this->writePrimaries[$resourceId])) { + throw new ResolverException( + "Write primary not found: {$resourceId}", + ResolverException::NOT_FOUND, + ['resourceId' => $resourceId, 'route' => 'write'] + ); + } + + $config = $this->writePrimaries[$resourceId]; + + return new Result( + endpoint: "{$config['host']}:{$config['port']}", + metadata: [ + 'resourceId' => $resourceId, + 'username' => $config['username'], + 'route' => 'write', + ] + ); + } +} + +/** + * Failover resolver that tries a primary resolver first and falls back + * to a secondary resolver if the primary fails. This simulates the + * production pattern where the Edge service might be unavailable and + * a secondary backend provides resilience. + */ +class EdgeFailoverResolver implements Resolver +{ + protected bool $failedOver = false; + + /** @var array}> */ + protected array $connects = []; + + /** @var array}> */ + protected array $disconnects = []; + + /** @var array}> */ + protected array $activities = []; + + /** @var array */ + protected array $invalidations = []; + + public function __construct( + protected Resolver $primary, + protected Resolver $secondary + ) { + } + + public function resolve(string $resourceId): Result + { + $this->failedOver = false; + + try { + return $this->primary->resolve($resourceId); + } catch (ResolverException $e) { + $this->failedOver = true; + + // Try secondary; let its exception propagate if it also fails + return $this->secondary->resolve($resourceId); + } + } + + public function didFailover(): bool + { + return $this->failedOver; + } + + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function onDisconnect(string $resourceId, array $metadata = []): void + { + $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function track(string $resourceId, array $metadata = []): void + { + $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function purge(string $resourceId): void + { + $this->invalidations[] = $resourceId; + $this->primary->purge($resourceId); + $this->secondary->purge($resourceId); + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'resolver' => 'edge-failover', + 'failedOver' => $this->failedOver, + 'primary' => $this->primary->getStats(), + 'secondary' => $this->secondary->getStats(), + ]; + } +} diff --git a/tests/MockReadWriteResolver.php b/tests/MockReadWriteResolver.php new file mode 100644 index 0000000..cf74195 --- /dev/null +++ b/tests/MockReadWriteResolver.php @@ -0,0 +1,76 @@ + */ + protected array $routeLog = []; + + public function setReadEndpoint(string $endpoint): self + { + $this->readEndpoint = $endpoint; + + return $this; + } + + public function setWriteEndpoint(string $endpoint): self + { + $this->writeEndpoint = $endpoint; + + return $this; + } + + public function resolveRead(string $resourceId): Result + { + $this->routeLog[] = ['resourceId' => $resourceId, 'type' => 'read']; + + if ($this->readEndpoint === null) { + throw new Exception('No read endpoint configured', Exception::NOT_FOUND); + } + + return new Result( + endpoint: $this->readEndpoint, + metadata: ['resourceId' => $resourceId, 'route' => 'read'] + ); + } + + public function resolveWrite(string $resourceId): Result + { + $this->routeLog[] = ['resourceId' => $resourceId, 'type' => 'write']; + + if ($this->writeEndpoint === null) { + throw new Exception('No write endpoint configured', Exception::NOT_FOUND); + } + + return new Result( + endpoint: $this->writeEndpoint, + metadata: ['resourceId' => $resourceId, 'route' => 'write'] + ); + } + + /** + * @return array + */ + public function getRouteLog(): array + { + return $this->routeLog; + } + + public function reset(): void + { + parent::reset(); + $this->routeLog = []; + } +} diff --git a/tests/MockResolver.php b/tests/MockResolver.php new file mode 100644 index 0000000..0099987 --- /dev/null +++ b/tests/MockResolver.php @@ -0,0 +1,143 @@ +}> */ + protected array $connects = []; + + /** @var array}> */ + protected array $disconnects = []; + + /** @var array}> */ + protected array $activities = []; + + /** @var array */ + protected array $invalidations = []; + + public function setEndpoint(string $endpoint): self + { + $this->endpoint = $endpoint; + $this->exception = null; + + return $this; + } + + public function setException(\Exception $exception): self + { + $this->exception = $exception; + $this->endpoint = null; + + return $this; + } + + public function resolve(string $resourceId): Result + { + if ($this->exception !== null) { + throw $this->exception; + } + + if ($this->endpoint === null) { + throw new Exception('No endpoint configured', Exception::NOT_FOUND); + } + + return new Result( + endpoint: $this->endpoint, + metadata: ['resourceId' => $resourceId] + ); + } + + /** + * @param array $metadata + */ + public function onConnect(string $resourceId, array $metadata = []): void + { + $this->connects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + /** + * @param array $metadata + */ + public function onDisconnect(string $resourceId, array $metadata = []): void + { + $this->disconnects[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + /** + * @param array $metadata + */ + public function track(string $resourceId, array $metadata = []): void + { + $this->activities[] = ['resourceId' => $resourceId, 'metadata' => $metadata]; + } + + public function purge(string $resourceId): void + { + $this->invalidations[] = $resourceId; + } + + /** + * @return array + */ + public function getStats(): array + { + return [ + 'resolver' => 'mock', + 'connects' => count($this->connects), + 'disconnects' => count($this->disconnects), + 'activities' => count($this->activities), + ]; + } + + /** + * @return array}> + */ + public function getConnects(): array + { + return $this->connects; + } + + /** + * @return array}> + */ + public function getDisconnects(): array + { + return $this->disconnects; + } + + /** + * @return array}> + */ + public function getActivities(): array + { + return $this->activities; + } + + /** + * @return array + */ + public function getInvalidations(): array + { + return $this->invalidations; + } + + public function reset(): void + { + $this->connects = []; + $this->disconnects = []; + $this->activities = []; + $this->invalidations = []; + } +} diff --git a/tests/Performance/PerformanceTest.php b/tests/Performance/PerformanceTest.php new file mode 100644 index 0000000..a447960 --- /dev/null +++ b/tests/Performance/PerformanceTest.php @@ -0,0 +1,860 @@ + + */ + private static array $results = []; + + public static function tearDownAfterClass(): void + { + if (empty(self::$results)) { + return; + } + + echo "\n"; + echo "=================================================================\n"; + echo " PERFORMANCE BENCHMARK RESULTS\n"; + echo "=================================================================\n"; + echo sprintf("%-35s %15s %10s %10s\n", 'Metric', 'Value', 'Target', 'Status'); + echo "-----------------------------------------------------------------\n"; + + foreach (self::$results as $name => $result) { + $targetStr = $result['target'] !== null + ? sprintf('%.2f', $result['target']) + : 'N/A'; + + $statusStr = match ($result['passed']) { + true => 'PASS', + false => 'FAIL', + null => '-', + }; + + echo sprintf( + "%-35s %12.2f %s %10s %10s\n", + $name, + $result['value'], + $result['unit'], + $targetStr, + $statusStr, + ); + } + + echo "=================================================================\n\n"; + } + + protected function setUp(): void + { + if (empty(getenv('PERF_TEST_ENABLED'))) { + $this->markTestSkipped('Performance tests disabled. Set PERF_TEST_ENABLED=1 to run.'); + } + + $this->host = getenv('PERF_PROXY_HOST') ?: '127.0.0.1'; + $this->port = (int) (getenv('PERF_PROXY_PORT') ?: 5432); + $this->iterations = (int) (getenv('PERF_ITERATIONS') ?: 1000); + $this->warmupIterations = (int) (getenv('PERF_WARMUP_ITERATIONS') ?: 100); + $this->databaseId = getenv('PERF_DATABASE_ID') ?: 'test-db'; + $this->targetConnRate = (int) (getenv('PERF_TARGET_CONN_RATE') ?: 10000); + $this->maxConnections = (int) (getenv('PERF_MAX_CONNECTIONS') ?: 10000); + $this->readWriteSplitPort = (int) (getenv('PERF_READ_WRITE_SPLIT_PORT') ?: 0); + } + + /** + * Measure how many TCP connections per second can be established + * and complete the PostgreSQL startup handshake through the proxy. + */ + public function testConnectionRate(): void + { + self::log("Measuring connection rate (target: >{$this->targetConnRate}/sec)"); + + // Warmup + for ($i = 0; $i < $this->warmupIterations; $i++) { + $sock = $this->connectAndStartup(); + if ($sock !== false) { + fclose($sock); + } + } + + // Benchmark + $successful = 0; + $failed = 0; + $start = hrtime(true); + + for ($i = 0; $i < $this->iterations; $i++) { + $sock = $this->connectAndStartup(); + if ($sock !== false) { + $successful++; + fclose($sock); + } else { + $failed++; + } + } + + $elapsed = (hrtime(true) - $start) / 1e9; // seconds + $rate = $successful / $elapsed; + + self::log(sprintf( + "Connection rate: %.0f/sec (%d successful, %d failed in %.3fs)", + $rate, + $successful, + $failed, + $elapsed, + )); + + $this->recordResult('connection_rate', $rate, '/sec', $this->targetConnRate); + + $this->assertGreaterThan(0, $successful, 'Should establish at least one connection'); + $this->assertGreaterThan( + $this->targetConnRate, + $rate, + sprintf('Connection rate %.0f/sec is below target %d/sec', $rate, $this->targetConnRate), + ); + } + + /** + * Measure queries per second through the proxy by sending PostgreSQL + * simple query protocol messages and counting responses. + */ + public function testQueryThroughput(): void + { + self::log("Measuring query throughput over {$this->iterations} queries"); + + $sock = $this->connectAndStartup(); + $this->assertNotFalse($sock, 'Failed to establish connection for throughput test'); + + // Read and discard the startup response + $this->readResponse($sock, 1.0); + + // Warmup + for ($i = 0; $i < $this->warmupIterations; $i++) { + $this->sendSimpleQuery($sock, 'SELECT 1'); + $this->readResponse($sock, 1.0); + } + + // Benchmark + $successful = 0; + $start = hrtime(true); + + for ($i = 0; $i < $this->iterations; $i++) { + $this->sendSimpleQuery($sock, 'SELECT 1'); + $response = $this->readResponse($sock, 1.0); + if ($response !== false && strlen($response) > 0) { + $successful++; + } + } + + $elapsed = (hrtime(true) - $start) / 1e9; + $qps = $successful / $elapsed; + $avgLatencyUs = ($elapsed / $successful) * 1e6; + + fclose($sock); + + self::log(sprintf( + "Query throughput: %.0f QPS (%.1f us avg latency, %d/%d successful in %.3fs)", + $qps, + $avgLatencyUs, + $successful, + $this->iterations, + $elapsed, + )); + + $this->recordResult('query_throughput', $qps, 'QPS', null); + $this->recordResult('query_avg_latency', $avgLatencyUs, 'us', null); + + $this->assertGreaterThan(0, $successful, 'Should complete at least one query'); + } + + /** + * Measure time from first connection to first query response. This includes + * the resolver lookup, backend connection establishment, and initial handshake. + */ + public function testColdStartLatency(): void + { + self::log("Measuring cold start latency"); + + // Run multiple cold starts and compute percentiles + $latencies = []; + $attempts = min($this->iterations, 50); // Cold starts are expensive + + for ($i = 0; $i < $attempts; $i++) { + $start = hrtime(true); + + $sock = $this->connectAndStartup(); + if ($sock === false) { + continue; + } + + // Read startup response + $startupResponse = $this->readResponse($sock, 5.0); + + // Send first query + $this->sendSimpleQuery($sock, 'SELECT 1'); + $queryResponse = $this->readResponse($sock, 5.0); + + $elapsed = (hrtime(true) - $start) / 1e6; // milliseconds + + if ($queryResponse !== false) { + $latencies[] = $elapsed; + } + + fclose($sock); + } + + $this->assertNotEmpty($latencies, 'Should complete at least one cold start'); + + sort($latencies); + $count = count($latencies); + $p50 = $latencies[(int) ($count * 0.5)]; + $p95 = $latencies[(int) ($count * 0.95)]; + $p99 = $latencies[min((int) ($count * 0.99), $count - 1)]; + $avg = array_sum($latencies) / $count; + + self::log(sprintf( + "Cold start latency: avg=%.2fms p50=%.2fms p95=%.2fms p99=%.2fms (%d samples)", + $avg, + $p50, + $p95, + $p99, + $count, + )); + + $this->recordResult('cold_start_avg', $avg, 'ms', null); + $this->recordResult('cold_start_p50', $p50, 'ms', null); + $this->recordResult('cold_start_p95', $p95, 'ms', null); + $this->recordResult('cold_start_p99', $p99, 'ms', null); + } + + /** + * Measure the time to detect backend failure and establish a new connection. + * This simulates what happens when the resolver returns a different backend + * after the current one goes down. + * + * Note: This test measures the client-side reconnection overhead, not the + * resolver/ReadWriteResolver failover itself (which depends on external state). + */ + public function testFailoverLatency(): void + { + self::log("Measuring failover/reconnection latency"); + + $latencies = []; + $attempts = min($this->iterations, 100); + + for ($i = 0; $i < $attempts; $i++) { + // Establish initial connection + $sock = $this->connectAndStartup(); + if ($sock === false) { + continue; + } + + $this->readResponse($sock, 1.0); + + // Close the connection (simulates backend going away) + fclose($sock); + + // Measure reconnection time + $start = hrtime(true); + + $newSock = $this->connectAndStartup(); + if ($newSock === false) { + continue; + } + + $reconnectResponse = $this->readResponse($newSock, 5.0); + $elapsed = (hrtime(true) - $start) / 1e6; // milliseconds + + if ($reconnectResponse !== false) { + $latencies[] = $elapsed; + } + + fclose($newSock); + } + + $this->assertNotEmpty($latencies, 'Should complete at least one reconnection'); + + sort($latencies); + $count = count($latencies); + $p50 = $latencies[(int) ($count * 0.5)]; + $p95 = $latencies[(int) ($count * 0.95)]; + $avg = array_sum($latencies) / $count; + + self::log(sprintf( + "Failover latency: avg=%.2fms p50=%.2fms p95=%.2fms (%d samples)", + $avg, + $p50, + $p95, + $count, + )); + + $this->recordResult('failover_avg', $avg, 'ms', null); + $this->recordResult('failover_p50', $p50, 'ms', null); + $this->recordResult('failover_p95', $p95, 'ms', null); + } + + /** + * Send increasingly large payloads (1KB, 10KB, 100KB, 1MB, 10MB) through + * the proxy and measure throughput at each size. + */ + public function testLargePayloadThroughput(): void + { + $sizes = [ + '1KB' => 1024, + '10KB' => 10 * 1024, + '100KB' => 100 * 1024, + '1MB' => 1024 * 1024, + '10MB' => 10 * 1024 * 1024, + ]; + + foreach ($sizes as $label => $size) { + self::log("Testing payload throughput at {$label}"); + + $sock = $this->connectAndStartup(); + if ($sock === false) { + self::log(" Skipping {$label}: connection failed"); + continue; + } + + // Read startup response + $this->readResponse($sock, 2.0); + + // Build a query payload of the target size + // Use a PostgreSQL simple query with a large string literal + $padding = str_repeat('X', max(0, $size - 64)); + $query = "SELECT '{$padding}'"; + + $iterationsForSize = match (true) { + $size <= 1024 => 500, + $size <= 10240 => 200, + $size <= 102400 => 50, + $size <= 1048576 => 10, + default => 3, + }; + + $totalBytes = 0; + $successful = 0; + $start = hrtime(true); + + for ($i = 0; $i < $iterationsForSize; $i++) { + $this->sendSimpleQuery($sock, $query); + $response = $this->readResponse($sock, 10.0); + if ($response !== false) { + $totalBytes += strlen($query) + strlen($response); + $successful++; + } + } + + $elapsed = (hrtime(true) - $start) / 1e9; + $throughputMBps = ($totalBytes / (1024 * 1024)) / $elapsed; + + fclose($sock); + + self::log(sprintf( + " %s: %.2f MB/s throughput (%d/%d successful, %.3fs elapsed)", + $label, + $throughputMBps, + $successful, + $iterationsForSize, + $elapsed, + )); + + $this->recordResult("payload_{$label}_throughput", $throughputMBps, 'MB/s', null); + + $this->assertGreaterThan(0, $successful, "Should complete at least one {$label} transfer"); + } + } + + /** + * Open connections until the max_connections limit is reached. + * Verify the proxy handles this gracefully (rejects with an error + * rather than crashing or hanging). + */ + public function testConnectionPoolExhaustion(): void + { + $targetConnections = min($this->maxConnections, 5000); // Cap for safety + self::log("Testing connection exhaustion up to {$targetConnections} connections"); + + /** @var resource[] $sockets */ + $sockets = []; + $peakConnections = 0; + $firstRefusalAt = null; + + for ($i = 0; $i < $targetConnections; $i++) { + $sock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 0.5, + ); + + if ($sock === false) { + $firstRefusalAt = $i; + self::log(" Connection refused at connection #{$i}: [{$errno}] {$errstr}"); + break; + } + + stream_set_timeout($sock, 0, 100000); // 100ms timeout + $sockets[] = $sock; + $peakConnections = $i + 1; + + // Log progress every 1000 connections + if ($peakConnections % 1000 === 0) { + self::log(" Opened {$peakConnections} connections..."); + } + } + + self::log(sprintf( + "Peak connections: %d (refusal at: %s)", + $peakConnections, + $firstRefusalAt !== null ? "#{$firstRefusalAt}" : 'none', + )); + + $this->recordResult('peak_connections', (float) $peakConnections, 'conn', null); + + // Verify we can still connect after closing some connections + $closedCount = min(100, count($sockets)); + for ($i = 0; $i < $closedCount; $i++) { + $sock = array_pop($sockets); + if ($sock !== null) { + fclose($sock); + } + } + + // Small delay for the proxy to process disconnections + usleep(100000); // 100ms + + $recoverySock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 2.0, + ); + + if ($recoverySock !== false) { + self::log(" Recovery connection successful after releasing {$closedCount} connections"); + fclose($recoverySock); + } else { + self::log(" Recovery connection failed: [{$errno}] {$errstr}"); + } + + // Clean up remaining sockets + foreach ($sockets as $sock) { + fclose($sock); + } + + $this->assertGreaterThan(0, $peakConnections, 'Should open at least one connection'); + + // If we hit refusal, verify it was at a reasonable point + if ($firstRefusalAt !== null) { + $this->assertGreaterThan( + 10, + $firstRefusalAt, + 'Proxy should handle at least 10 connections before refusing', + ); + } + } + + /** + * Measure query latency with 10, 100, 1000, and 10000 concurrent connections + * to observe how the proxy scales under increasing load. + */ + public function testConcurrentConnectionScaling(): void + { + $concurrencyLevels = [10, 100, 1000, 10000]; + + foreach ($concurrencyLevels as $level) { + if ($level > $this->maxConnections) { + self::log("Skipping concurrency level {$level} (exceeds max {$this->maxConnections})"); + continue; + } + + self::log("Testing with {$level} concurrent connections"); + + // Establish connections + /** @var resource[] $sockets */ + $sockets = []; + $established = 0; + + for ($i = 0; $i < $level; $i++) { + $sock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 1.0, + ); + + if ($sock === false) { + break; + } + + stream_set_timeout($sock, 1); + stream_set_blocking($sock, false); + $sockets[] = $sock; + $established++; + } + + if ($established < $level) { + self::log(" Only established {$established}/{$level} connections"); + } + + if ($established === 0) { + self::log(" No connections established, skipping"); + $this->recordResult("latency_at_{$level}", 0, 'ms', null); + continue; + } + + // Send startup on all connections + foreach ($sockets as $sock) { + stream_set_blocking($sock, true); + stream_set_timeout($sock, 1); + $startupMsg = $this->buildStartupMessage($this->databaseId); + @fwrite($sock, $startupMsg); + } + + // Small settle time + usleep(50000); + + // Measure round-trip latency on a sample of connections + $sampleSize = min(100, $established); + $sampleSockets = array_slice($sockets, 0, $sampleSize); + $latencies = []; + + foreach ($sampleSockets as $sock) { + stream_set_blocking($sock, true); + stream_set_timeout($sock, 1); + + // Drain any pending data + $this->readResponse($sock, 0.1); + + $start = hrtime(true); + $this->sendSimpleQuery($sock, 'SELECT 1'); + $response = $this->readResponse($sock, 2.0); + $elapsed = (hrtime(true) - $start) / 1e6; + + if ($response !== false && strlen($response) > 0) { + $latencies[] = $elapsed; + } + } + + // Clean up + foreach ($sockets as $sock) { + @fclose($sock); + } + + if (!empty($latencies)) { + sort($latencies); + $count = count($latencies); + $avg = array_sum($latencies) / $count; + $p50 = $latencies[(int) ($count * 0.5)]; + $p99 = $latencies[min((int) ($count * 0.99), $count - 1)]; + + self::log(sprintf( + " %d conns: avg=%.2fms p50=%.2fms p99=%.2fms (%d samples)", + $level, + $avg, + $p50, + $p99, + $count, + )); + + $this->recordResult("latency_at_{$level}_avg", $avg, 'ms', null); + $this->recordResult("latency_at_{$level}_p99", $p99, 'ms', null); + } else { + self::log(" No successful queries at {$level} concurrency"); + $this->recordResult("latency_at_{$level}_avg", 0, 'ms', null); + } + } + + // At minimum, the lowest concurrency level should work + $this->assertArrayHasKey('latency_at_10_avg', self::$results); + } + + /** + * Compare query latency with and without read/write split enabled. + * Measures the overhead introduced by query classification. + */ + public function testReadWriteSplitOverhead(): void + { + if ($this->readWriteSplitPort <= 0) { + $this->markTestSkipped( + 'Read/write split test requires PERF_READ_WRITE_SPLIT_PORT to be set' + ); + } + + $queriesPerRun = min($this->iterations, 5000); + + // Measure without read/write split (standard port) + self::log("Measuring latency without read/write split ({$queriesPerRun} queries)"); + + $standardLatencies = $this->benchmarkQueryLatency($this->host, $this->port, $queriesPerRun); + + // Measure with read/write split + self::log("Measuring latency with read/write split ({$queriesPerRun} queries)"); + + $splitLatencies = $this->benchmarkQueryLatency($this->host, $this->readWriteSplitPort, $queriesPerRun); + + if (empty($standardLatencies) || empty($splitLatencies)) { + $this->markTestSkipped('Could not collect latency samples for comparison'); + } + + $standardAvg = array_sum($standardLatencies) / count($standardLatencies); + $splitAvg = array_sum($splitLatencies) / count($splitLatencies); + $overheadUs = $splitAvg - $standardAvg; + $overheadPct = ($overheadUs / $standardAvg) * 100; + + self::log(sprintf( + "Standard avg: %.2fus, Split avg: %.2fus, Overhead: %.2fus (%.1f%%)", + $standardAvg, + $splitAvg, + $overheadUs, + $overheadPct, + )); + + $this->recordResult('rw_split_standard_avg', $standardAvg, 'us', null); + $this->recordResult('rw_split_split_avg', $splitAvg, 'us', null); + $this->recordResult('rw_split_overhead', $overheadUs, 'us', null); + $this->recordResult('rw_split_overhead_pct', $overheadPct, '%', null); + + // The overhead should be minimal -- under 20% in most cases + $this->assertLessThan( + 20.0, + $overheadPct, + sprintf('Read/write split overhead is %.1f%% which exceeds 20%%', $overheadPct), + ); + } + + /** + * Build a PostgreSQL StartupMessage with the database name encoding the + * database ID for the proxy resolver. + * + * Wire format: + * Int32 length (includes self) + * Int32 protocol version (3.0 = 196608) + * String "user" \0 String \0 + * String "database" \0 String "db-" \0 + * \0 (terminator) + */ + private function buildStartupMessage(string $databaseId): string + { + $params = "user\x00appwrite\x00database\x00db-{$databaseId}\x00\x00"; + $protocolVersion = pack('N', 196608); // 3.0 + $length = 4 + strlen($protocolVersion) + strlen($params); + + return pack('N', $length) . $protocolVersion . $params; + } + + /** + * Build a PostgreSQL Simple Query message. + * + * Wire format: + * Byte1 'Q' + * Int32 length (includes self but not message type) + * String query \0 + */ + private function buildSimpleQueryMessage(string $query): string + { + $queryWithNull = $query . "\x00"; + $length = 4 + strlen($queryWithNull); + + return 'Q' . pack('N', $length) . $queryWithNull; + } + + /** + * Connect to the proxy and send a PostgreSQL startup message. + * + * @return resource|false Socket resource on success, false on failure + */ + private function connectAndStartup(): mixed + { + $sock = @stream_socket_client( + "tcp://{$this->host}:{$this->port}", + $errno, + $errstr, + 2.0, + ); + + if ($sock === false) { + return false; + } + + stream_set_timeout($sock, 5); + + $startupMsg = $this->buildStartupMessage($this->databaseId); + $written = @fwrite($sock, $startupMsg); + + if ($written === false || $written === 0) { + fclose($sock); + return false; + } + + return $sock; + } + + /** + * Send a PostgreSQL simple query on an established connection. + * + * @param resource $sock + */ + private function sendSimpleQuery($sock, string $query): bool + { + $msg = $this->buildSimpleQueryMessage($query); + $written = @fwrite($sock, $msg); + + return $written !== false && $written > 0; + } + + /** + * Read a response from the proxy with a timeout. + * + * @param resource $sock + * @return string|false Response data or false on failure/timeout + */ + private function readResponse($sock, float $timeoutSeconds): string|false + { + $timeoutSec = (int) $timeoutSeconds; + $timeoutUsec = (int) (($timeoutSeconds - $timeoutSec) * 1e6); + stream_set_timeout($sock, $timeoutSec, $timeoutUsec); + + $data = @fread($sock, 65536); + + if ($data === false || $data === '') { + $meta = stream_get_meta_data($sock); + if ($meta['timed_out']) { + return false; + } + return false; + } + + return $data; + } + + /** + * Benchmark query latency on a given host:port and return latency array in microseconds. + * + * @return array Latencies in microseconds + */ + private function benchmarkQueryLatency(string $host, int $port, int $count): array + { + $sock = @stream_socket_client( + "tcp://{$host}:{$port}", + $errno, + $errstr, + 2.0, + ); + + if ($sock === false) { + return []; + } + + stream_set_timeout($sock, 5); + + // Send startup + $startupMsg = $this->buildStartupMessage($this->databaseId); + @fwrite($sock, $startupMsg); + + // Read startup response + $this->readResponse($sock, 2.0); + + // Warmup + for ($i = 0; $i < min(100, $count); $i++) { + $this->sendSimpleQuery($sock, 'SELECT 1'); + $this->readResponse($sock, 1.0); + } + + // Benchmark + $latencies = []; + + for ($i = 0; $i < $count; $i++) { + $start = hrtime(true); + $this->sendSimpleQuery($sock, 'SELECT 1'); + $response = $this->readResponse($sock, 2.0); + $elapsed = (hrtime(true) - $start) / 1e3; // microseconds + + if ($response !== false && strlen($response) > 0) { + $latencies[] = $elapsed; + } + } + + fclose($sock); + + return $latencies; + } + + /** + * Record a benchmark result for the summary table. + */ + private function recordResult(string $name, float $value, string $unit, ?float $target): void + { + $passed = null; + if ($target !== null) { + // For rates/throughput, higher is better + if (str_contains($unit, '/sec') || str_contains($unit, 'QPS') || str_contains($unit, 'MB/s')) { + $passed = $value >= $target; + } else { + // For latency, lower is better + $passed = $value <= $target; + } + } + + self::$results[$name] = [ + 'metric' => $name, + 'value' => $value, + 'unit' => $unit, + 'target' => $target, + 'passed' => $passed, + ]; + } + + /** + * Log a message with timestamp. + */ + private static function log(string $message): void + { + $timestamp = date('Y-m-d H:i:s'); + echo "[{$timestamp}] [PERF] {$message}\n"; + } +} diff --git a/tests/ProtocolTest.php b/tests/ProtocolTest.php new file mode 100644 index 0000000..17a3937 --- /dev/null +++ b/tests/ProtocolTest.php @@ -0,0 +1,59 @@ +assertSame('http', Protocol::HTTP->value); + $this->assertSame('smtp', Protocol::SMTP->value); + $this->assertSame('tcp', Protocol::TCP->value); + $this->assertSame('postgresql', Protocol::PostgreSQL->value); + $this->assertSame('mysql', Protocol::MySQL->value); + $this->assertSame('mongodb', Protocol::MongoDB->value); + } + + public function testProtocolCount(): void + { + $cases = Protocol::cases(); + $this->assertCount(6, $cases); + } + + public function testProtocolFromValue(): void + { + $this->assertSame(Protocol::HTTP, Protocol::from('http')); + $this->assertSame(Protocol::SMTP, Protocol::from('smtp')); + $this->assertSame(Protocol::TCP, Protocol::from('tcp')); + $this->assertSame(Protocol::PostgreSQL, Protocol::from('postgresql')); + $this->assertSame(Protocol::MySQL, Protocol::from('mysql')); + $this->assertSame(Protocol::MongoDB, Protocol::from('mongodb')); + } + + public function testProtocolTryFromInvalidReturnsNull(): void + { + $invalid = Protocol::tryFrom('invalid'); + $empty = Protocol::tryFrom(''); + $uppercase = Protocol::tryFrom('HTTP'); // case-sensitive + + $this->assertSame(null, $invalid); + $this->assertSame(null, $empty); + $this->assertSame(null, $uppercase); + } + + public function testProtocolFromInvalidThrows(): void + { + $this->expectException(\ValueError::class); + Protocol::from('invalid'); + } + + public function testProtocolIsBackedEnum(): void + { + $reflection = new \ReflectionEnum(Protocol::class); + $this->assertTrue($reflection->isBacked()); + $this->assertSame('string', $reflection->getBackingType()->getName()); + } +} diff --git a/tests/QueryParserTest.php b/tests/QueryParserTest.php new file mode 100644 index 0000000..b093b98 --- /dev/null +++ b/tests/QueryParserTest.php @@ -0,0 +1,631 @@ +pgParser = new PostgreSQL(); + $this->mysqlParser = new MySQL(); + } + + /** + * Build a PostgreSQL Simple Query ('Q') message + * + * Format: 'Q' | int32 length | query string \0 + */ + private function buildPgQuery(string $sql): string + { + $body = $sql . "\x00"; + $length = \strlen($body) + 4; // length includes itself but not the type byte + + return 'Q' . \pack('N', $length) . $body; + } + + /** + * Build a PostgreSQL Parse ('P') message (extended query protocol) + */ + private function buildPgParse(string $stmtName, string $sql): string + { + $body = $stmtName . "\x00" . $sql . "\x00" . \pack('n', 0); // 0 param types + $length = \strlen($body) + 4; + + return 'P' . \pack('N', $length) . $body; + } + + /** + * Build a PostgreSQL Bind ('B') message + */ + private function buildPgBind(): string + { + $body = "\x00\x00" . \pack('n', 0) . \pack('n', 0) . \pack('n', 0); + $length = \strlen($body) + 4; + + return 'B' . \pack('N', $length) . $body; + } + + /** + * Build a PostgreSQL Execute ('E') message + */ + private function buildPgExecute(): string + { + $body = "\x00" . \pack('N', 0); + $length = \strlen($body) + 4; + + return 'E' . \pack('N', $length) . $body; + } + + public function testPgSelectQuery(): void + { + $data = $this->buildPgQuery('SELECT * FROM users WHERE id = 1'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgSelectLowercase(): void + { + $data = $this->buildPgQuery('select id, name from users'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgSelectMixedCase(): void + { + $data = $this->buildPgQuery('SeLeCt * FROM users'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgShowQuery(): void + { + $data = $this->buildPgQuery('SHOW TABLES'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgDescribeQuery(): void + { + $data = $this->buildPgQuery('DESCRIBE users'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgExplainQuery(): void + { + $data = $this->buildPgQuery('EXPLAIN SELECT * FROM users'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgTableQuery(): void + { + $data = $this->buildPgQuery('TABLE users'); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgValuesQuery(): void + { + $data = $this->buildPgQuery("VALUES (1, 'a'), (2, 'b')"); + $this->assertSame(QueryType::Read, $this->pgParser->parse($data)); + } + + public function testPgInsertQuery(): void + { + $data = $this->buildPgQuery("INSERT INTO users (name) VALUES ('test')"); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgUpdateQuery(): void + { + $data = $this->buildPgQuery("UPDATE users SET name = 'test' WHERE id = 1"); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgDeleteQuery(): void + { + $data = $this->buildPgQuery('DELETE FROM users WHERE id = 1'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgCreateTable(): void + { + $data = $this->buildPgQuery('CREATE TABLE test (id INT PRIMARY KEY)'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgDropTable(): void + { + $data = $this->buildPgQuery('DROP TABLE IF EXISTS test'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgAlterTable(): void + { + $data = $this->buildPgQuery('ALTER TABLE users ADD COLUMN email TEXT'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgTruncate(): void + { + $data = $this->buildPgQuery('TRUNCATE TABLE users'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgGrant(): void + { + $data = $this->buildPgQuery('GRANT SELECT ON users TO readonly'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgRevoke(): void + { + $data = $this->buildPgQuery('REVOKE ALL ON users FROM public'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgLockTable(): void + { + $data = $this->buildPgQuery('LOCK TABLE users IN ACCESS EXCLUSIVE MODE'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgCall(): void + { + $data = $this->buildPgQuery('CALL my_procedure()'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgDo(): void + { + $data = $this->buildPgQuery("DO $$ BEGIN RAISE NOTICE 'hello'; END $$"); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgBeginTransaction(): void + { + $data = $this->buildPgQuery('BEGIN'); + $this->assertSame(QueryType::TransactionBegin, $this->pgParser->parse($data)); + } + + public function testPgStartTransaction(): void + { + $data = $this->buildPgQuery('START TRANSACTION'); + $this->assertSame(QueryType::TransactionBegin, $this->pgParser->parse($data)); + } + + public function testPgCommit(): void + { + $data = $this->buildPgQuery('COMMIT'); + $this->assertSame(QueryType::TransactionEnd, $this->pgParser->parse($data)); + } + + public function testPgRollback(): void + { + $data = $this->buildPgQuery('ROLLBACK'); + $this->assertSame(QueryType::TransactionEnd, $this->pgParser->parse($data)); + } + + public function testPgSavepoint(): void + { + $data = $this->buildPgQuery('SAVEPOINT sp1'); + $this->assertSame(QueryType::Transaction, $this->pgParser->parse($data)); + } + + public function testPgReleaseSavepoint(): void + { + $data = $this->buildPgQuery('RELEASE SAVEPOINT sp1'); + $this->assertSame(QueryType::Transaction, $this->pgParser->parse($data)); + } + + public function testPgSetCommand(): void + { + $data = $this->buildPgQuery("SET search_path TO 'public'"); + $this->assertSame(QueryType::Transaction, $this->pgParser->parse($data)); + } + + public function testPgParseMessageRoutesToWrite(): void + { + $data = $this->buildPgParse('stmt1', 'SELECT * FROM users'); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgBindMessageRoutesToWrite(): void + { + $data = $this->buildPgBind(); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgExecuteMessageRoutesToWrite(): void + { + $data = $this->buildPgExecute(); + $this->assertSame(QueryType::Write, $this->pgParser->parse($data)); + } + + public function testPgTooShortPacket(): void + { + $this->assertSame(QueryType::Unknown, $this->pgParser->parse('Q')); + } + + public function testPgUnknownMessageType(): void + { + $data = 'X' . \pack('N', 5) . "\x00"; + $this->assertSame(QueryType::Unknown, $this->pgParser->parse($data)); + } + + /** + * Build a MySQL COM_QUERY packet + * + * Format: 3-byte length (LE) | 1-byte seq | 0x03 | query string + */ + private function buildMySQLQuery(string $sql): string + { + $payloadLen = 1 + \strlen($sql); // command byte + query + $header = \pack('V', $payloadLen); // 4 bytes, but MySQL uses 3 bytes length + 1 byte seq + $header[3] = "\x00"; // sequence id = 0 + + return $header . "\x03" . $sql; + } + + /** + * Build a MySQL COM_STMT_PREPARE packet + */ + private function buildMySQLStmtPrepare(string $sql): string + { + $payloadLen = 1 + \strlen($sql); + $header = \pack('V', $payloadLen); + $header[3] = "\x00"; + + return $header . "\x16" . $sql; + } + + /** + * Build a MySQL COM_STMT_EXECUTE packet + */ + private function buildMySQLStmtExecute(int $stmtId): string + { + $body = \pack('V', $stmtId) . "\x00" . \pack('V', 1); // stmt_id, flags, iteration_count + $payloadLen = 1 + \strlen($body); + $header = \pack('V', $payloadLen); + $header[3] = "\x00"; + + return $header . "\x17" . $body; + } + + public function testMysqlSelectQuery(): void + { + $data = $this->buildMySQLQuery('SELECT * FROM users WHERE id = 1'); + $this->assertSame(QueryType::Read, $this->mysqlParser->parse($data)); + } + + public function testMysqlSelectLowercase(): void + { + $data = $this->buildMySQLQuery('select id from users'); + $this->assertSame(QueryType::Read, $this->mysqlParser->parse($data)); + } + + public function testMysqlShowQuery(): void + { + $data = $this->buildMySQLQuery('SHOW DATABASES'); + $this->assertSame(QueryType::Read, $this->mysqlParser->parse($data)); + } + + public function testMysqlDescribeQuery(): void + { + $data = $this->buildMySQLQuery('DESCRIBE users'); + $this->assertSame(QueryType::Read, $this->mysqlParser->parse($data)); + } + + public function testMysqlDescQuery(): void + { + $data = $this->buildMySQLQuery('DESC users'); + $this->assertSame(QueryType::Read, $this->mysqlParser->parse($data)); + } + + public function testMysqlExplainQuery(): void + { + $data = $this->buildMySQLQuery('EXPLAIN SELECT * FROM users'); + $this->assertSame(QueryType::Read, $this->mysqlParser->parse($data)); + } + + public function testMysqlInsertQuery(): void + { + $data = $this->buildMySQLQuery("INSERT INTO users (name) VALUES ('test')"); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlUpdateQuery(): void + { + $data = $this->buildMySQLQuery("UPDATE users SET name = 'test' WHERE id = 1"); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlDeleteQuery(): void + { + $data = $this->buildMySQLQuery('DELETE FROM users WHERE id = 1'); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlCreateTable(): void + { + $data = $this->buildMySQLQuery('CREATE TABLE test (id INT PRIMARY KEY)'); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlDropTable(): void + { + $data = $this->buildMySQLQuery('DROP TABLE test'); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlAlterTable(): void + { + $data = $this->buildMySQLQuery('ALTER TABLE users ADD COLUMN email VARCHAR(255)'); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlTruncate(): void + { + $data = $this->buildMySQLQuery('TRUNCATE TABLE users'); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlBeginTransaction(): void + { + $data = $this->buildMySQLQuery('BEGIN'); + $this->assertSame(QueryType::TransactionBegin, $this->mysqlParser->parse($data)); + } + + public function testMysqlStartTransaction(): void + { + $data = $this->buildMySQLQuery('START TRANSACTION'); + $this->assertSame(QueryType::TransactionBegin, $this->mysqlParser->parse($data)); + } + + public function testMysqlCommit(): void + { + $data = $this->buildMySQLQuery('COMMIT'); + $this->assertSame(QueryType::TransactionEnd, $this->mysqlParser->parse($data)); + } + + public function testMysqlRollback(): void + { + $data = $this->buildMySQLQuery('ROLLBACK'); + $this->assertSame(QueryType::TransactionEnd, $this->mysqlParser->parse($data)); + } + + public function testMysqlSetCommand(): void + { + $data = $this->buildMySQLQuery("SET autocommit = 0"); + $this->assertSame(QueryType::Transaction, $this->mysqlParser->parse($data)); + } + + public function testMysqlStmtPrepareRoutesToWrite(): void + { + $data = $this->buildMySQLStmtPrepare('SELECT * FROM users WHERE id = ?'); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlStmtExecuteRoutesToWrite(): void + { + $data = $this->buildMySQLStmtExecute(1); + $this->assertSame(QueryType::Write, $this->mysqlParser->parse($data)); + } + + public function testMysqlTooShortPacket(): void + { + $this->assertSame(QueryType::Unknown, $this->mysqlParser->parse("\x00\x00")); + } + + public function testMysqlUnknownCommand(): void + { + // COM_QUIT = 0x01 + $header = \pack('V', 1); + $header[3] = "\x00"; + $data = $header . "\x01"; + $this->assertSame(QueryType::Unknown, $this->mysqlParser->parse($data)); + } + + public function testClassifyLeadingWhitespace(): void + { + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL(" \t\n SELECT * FROM users")); + } + + public function testClassifyLeadingLineComment(): void + { + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL("-- this is a comment\nSELECT * FROM users")); + } + + public function testClassifyLeadingBlockComment(): void + { + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL("/* block comment */ SELECT * FROM users")); + } + + public function testClassifyMultipleComments(): void + { + $sql = "-- line comment\n/* block comment */\n -- another line\n SELECT 1"; + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyNestedBlockComment(): void + { + // Note: SQL standard doesn't support nested block comments; parser stops at first */ + $sql = "/* outer /* inner */ SELECT 1"; + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyEmptyQuery(): void + { + $this->assertSame(QueryType::Unknown, $this->pgParser->classifySQL('')); + } + + public function testClassifyWhitespaceOnly(): void + { + $this->assertSame(QueryType::Unknown, $this->pgParser->classifySQL(" \t\n ")); + } + + public function testClassifyCommentOnly(): void + { + $this->assertSame(QueryType::Unknown, $this->pgParser->classifySQL('-- just a comment')); + } + + public function testClassifySelectWithParenthesis(): void + { + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL('SELECT(1)')); + } + + public function testClassifySelectWithSemicolon(): void + { + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL('SELECT;')); + } + + public function testClassifyCopyTo(): void + { + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL('COPY users TO STDOUT')); + } + + public function testClassifyCopyFrom(): void + { + $this->assertSame(QueryType::Write, $this->pgParser->classifySQL("COPY users FROM '/tmp/data.csv'")); + } + + public function testClassifyCopyAmbiguous(): void + { + // No direction keyword - defaults to WRITE for safety + $this->assertSame(QueryType::Write, $this->pgParser->classifySQL('COPY users')); + } + + public function testClassifyCteWithSelect(): void + { + $sql = 'WITH active_users AS (SELECT * FROM users WHERE active = true) SELECT * FROM active_users'; + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyCteWithInsert(): void + { + $sql = 'WITH new_data AS (SELECT 1 AS id) INSERT INTO users SELECT * FROM new_data'; + $this->assertSame(QueryType::Write, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyCteWithUpdate(): void + { + $sql = 'WITH src AS (SELECT id FROM staging) UPDATE users SET active = true FROM src WHERE users.id = src.id'; + $this->assertSame(QueryType::Write, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyCteWithDelete(): void + { + $sql = 'WITH old AS (SELECT id FROM users WHERE created_at < now()) DELETE FROM users WHERE id IN (SELECT id FROM old)'; + $this->assertSame(QueryType::Write, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyCteRecursiveSelect(): void + { + $sql = 'WITH RECURSIVE tree AS (SELECT id, parent_id FROM categories WHERE parent_id IS NULL UNION ALL SELECT c.id, c.parent_id FROM categories c JOIN tree t ON c.parent_id = t.id) SELECT * FROM tree'; + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); + } + + public function testClassifyCteNoFinalKeyword(): void + { + // Bare WITH with no recognizable final statement - defaults to READ + $sql = 'WITH x AS (SELECT 1)'; + $this->assertSame(QueryType::Read, $this->pgParser->classifySQL($sql)); + } + + public function testExtractKeywordSimple(): void + { + $this->assertSame('SELECT', $this->pgParser->extractKeyword('SELECT * FROM users')); + } + + public function testExtractKeywordLowercase(): void + { + $this->assertSame('INSERT', $this->pgParser->extractKeyword('insert into users')); + } + + public function testExtractKeywordWithWhitespace(): void + { + $this->assertSame('DELETE', $this->pgParser->extractKeyword(" \t\n DELETE FROM users")); + } + + public function testExtractKeywordWithComments(): void + { + $this->assertSame('UPDATE', $this->pgParser->extractKeyword("-- comment\nUPDATE users SET x = 1")); + } + + public function testExtractKeywordEmpty(): void + { + $this->assertSame('', $this->pgParser->extractKeyword('')); + } + + public function testExtractKeywordParenthesized(): void + { + $this->assertSame('SELECT', $this->pgParser->extractKeyword('SELECT(1)')); + } + + public function testParsePerformance(): void + { + $pgData = $this->buildPgQuery('SELECT * FROM users WHERE id = 1'); + $mysqlData = $this->buildMySQLQuery('SELECT * FROM users WHERE id = 1'); + + $iterations = 100_000; + + // PostgreSQL parse performance + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->pgParser->parse($pgData); + } + $pgElapsed = (\hrtime(true) - $start) / 1_000_000_000; // seconds + $pgPerQuery = ($pgElapsed / $iterations) * 1_000_000; // microseconds + + // MySQL parse performance + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->mysqlParser->parse($mysqlData); + } + $mysqlElapsed = (\hrtime(true) - $start) / 1_000_000_000; + $mysqlPerQuery = ($mysqlElapsed / $iterations) * 1_000_000; + + // Both should be under 2 microseconds per parse (relaxed for CI runners) + $this->assertLessThan( + 2.0, + $pgPerQuery, + \sprintf('PostgreSQL parse took %.3f us/query (target: < 2.0 us)', $pgPerQuery) + ); + $this->assertLessThan( + 2.0, + $mysqlPerQuery, + \sprintf('MySQL parse took %.3f us/query (target: < 2.0 us)', $mysqlPerQuery) + ); + } + + public function testClassifySqlPerformance(): void + { + $queries = [ + 'SELECT * FROM users WHERE id = 1', + "INSERT INTO logs (msg) VALUES ('test')", + 'BEGIN', + ' /* comment */ SELECT 1', + 'WITH cte AS (SELECT 1) SELECT * FROM cte', + ]; + + $iterations = 100_000; + + $start = \hrtime(true); + for ($i = 0; $i < $iterations; $i++) { + $this->pgParser->classifySQL($queries[$i % \count($queries)]); + } + $elapsed = (\hrtime(true) - $start) / 1_000_000_000; + $perQuery = ($elapsed / $iterations) * 1_000_000; + + // Threshold is 2us to account for CTE queries which require parenthesis-depth scanning. + // Simple queries (SELECT, INSERT, BEGIN) are well under 1us individually. + $this->assertLessThan( + 2.0, + $perQuery, + \sprintf('classifySQL took %.3f us/query (target: < 2.0 us)', $perQuery) + ); + } +} diff --git a/tests/ReadWriteSplitTest.php b/tests/ReadWriteSplitTest.php new file mode 100644 index 0000000..d8e4df5 --- /dev/null +++ b/tests/ReadWriteSplitTest.php @@ -0,0 +1,335 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->rwResolver = new MockReadWriteResolver(); + $this->basicResolver = new MockResolver(); + } + + public function testReadWriteSplitDisabledByDefault(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $this->assertFalse($adapter->isReadWriteSplit()); + } + + public function testReadWriteSplitCanBeEnabled(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $this->assertTrue($adapter->isReadWriteSplit()); + } + + public function testReadWriteSplitCanBeDisabled(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setReadWriteSplit(false); + $this->assertFalse($adapter->isReadWriteSplit()); + } + + /** + * Build a PostgreSQL Simple Query message + */ + private function buildPgQuery(string $sql): string + { + $body = $sql . "\x00"; + $length = \strlen($body) + 4; + + return 'Q' . \pack('N', $length) . $body; + } + + /** + * Build a MySQL COM_QUERY packet + */ + private function buildMySQLQuery(string $sql): string + { + $payloadLen = 1 + \strlen($sql); + $header = \pack('V', $payloadLen); + $header[3] = "\x00"; + + return $header . "\x03" . $sql; + } + + public function testClassifyPgSelectAsRead(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $data = $this->buildPgQuery('SELECT * FROM users'); + $this->assertSame(QueryType::Read, $adapter->classifyQuery($data, 1)); + } + + public function testClassifyPgInsertAsWrite(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $data = $this->buildPgQuery("INSERT INTO users (name) VALUES ('x')"); + $this->assertSame(QueryType::Write, $adapter->classifyQuery($data, 1)); + } + + public function testClassifyMysqlSelectAsRead(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 3306); + $adapter->setReadWriteSplit(true); + + $data = $this->buildMySQLQuery('SELECT * FROM users'); + $this->assertSame(QueryType::Read, $adapter->classifyQuery($data, 1)); + } + + public function testClassifyMysqlInsertAsWrite(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 3306); + $adapter->setReadWriteSplit(true); + + $data = $this->buildMySQLQuery("INSERT INTO users (name) VALUES ('x')"); + $this->assertSame(QueryType::Write, $adapter->classifyQuery($data, 1)); + } + + public function testClassifyReturnsWriteWhenSplitDisabled(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + // Read/write split is disabled by default + + $data = $this->buildPgQuery('SELECT * FROM users'); + $this->assertSame(QueryType::Write, $adapter->classifyQuery($data, 1)); + } + + public function testBeginPinsConnectionToPrimary(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + // Not pinned initially + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + + // BEGIN pins + $data = $this->buildPgQuery('BEGIN'); + $result = $adapter->classifyQuery($data, $clientFd); + $this->assertSame(QueryType::Write, $result); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + } + + public function testPinnedConnectionRoutesSelectToWrite(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + // Begin transaction + $adapter->classifyQuery($this->buildPgQuery('BEGIN'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + + // SELECT should still route to WRITE when pinned + $data = $this->buildPgQuery('SELECT * FROM users'); + $this->assertSame(QueryType::Write, $adapter->classifyQuery($data, $clientFd)); + } + + public function testCommitUnpinsConnection(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + // Begin transaction + $adapter->classifyQuery($this->buildPgQuery('BEGIN'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + + // COMMIT unpins + $adapter->classifyQuery($this->buildPgQuery('COMMIT'), $clientFd); + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + + // Now SELECT should route to READ again + $data = $this->buildPgQuery('SELECT * FROM users'); + $this->assertSame(QueryType::Read, $adapter->classifyQuery($data, $clientFd)); + } + + public function testRollbackUnpinsConnection(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + // Begin transaction + $adapter->classifyQuery($this->buildPgQuery('BEGIN'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + + // ROLLBACK unpins + $adapter->classifyQuery($this->buildPgQuery('ROLLBACK'), $clientFd); + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + } + + public function testStartTransactionPinsConnection(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + $adapter->classifyQuery($this->buildPgQuery('START TRANSACTION'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + } + + public function testMysqlBeginPinsConnection(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 3306); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + $adapter->classifyQuery($this->buildMySQLQuery('BEGIN'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + } + + public function testMysqlCommitUnpinsConnection(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 3306); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + $adapter->classifyQuery($this->buildMySQLQuery('BEGIN'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + + $adapter->classifyQuery($this->buildMySQLQuery('COMMIT'), $clientFd); + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + } + + public function testClearConnectionStateRemovesPin(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + $adapter->classifyQuery($this->buildPgQuery('BEGIN'), $clientFd); + $this->assertTrue($adapter->isConnectionPinned($clientFd)); + + $adapter->clearConnectionState($clientFd); + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + } + + public function testPinningIsPerConnection(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $fd1 = 1; + $fd2 = 2; + + // Pin fd1 + $adapter->classifyQuery($this->buildPgQuery('BEGIN'), $fd1); + $this->assertTrue($adapter->isConnectionPinned($fd1)); + $this->assertFalse($adapter->isConnectionPinned($fd2)); + + // fd2 can still read + $this->assertSame(QueryType::Read, $adapter->classifyQuery($this->buildPgQuery('SELECT 1'), $fd2)); + + // fd1 is pinned to write + $this->assertSame(QueryType::Write, $adapter->classifyQuery($this->buildPgQuery('SELECT 1'), $fd1)); + } + + public function testRouteQueryReadUsesReadEndpoint(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + $this->rwResolver->setReadEndpoint('replica.db:5432'); + $this->rwResolver->setWriteEndpoint('primary.db:5432'); + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Read); + $this->assertSame('replica.db:5432', $result->endpoint); + $this->assertSame('read', $result->metadata['route']); + } + + public function testRouteQueryWriteUsesWriteEndpoint(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + $this->rwResolver->setReadEndpoint('replica.db:5432'); + $this->rwResolver->setWriteEndpoint('primary.db:5432'); + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Write); + $this->assertSame('primary.db:5432', $result->endpoint); + $this->assertSame('write', $result->metadata['route']); + } + + public function testRouteQueryFallsBackWhenSplitDisabled(): void + { + $this->rwResolver->setEndpoint('default.db:5432'); + $this->rwResolver->setReadEndpoint('replica.db:5432'); + $this->rwResolver->setWriteEndpoint('primary.db:5432'); + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + // read/write split is disabled + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Read); + $this->assertSame('default.db:5432', $result->endpoint); + } + + public function testRouteQueryFallsBackWithBasicResolver(): void + { + $this->basicResolver->setEndpoint('default.db:5432'); + + $adapter = new TCPAdapter($this->basicResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + // Even with read/write split enabled, basic resolver uses default route() + $result = $adapter->routeQuery('test-db', QueryType::Read); + $this->assertSame('default.db:5432', $result->endpoint); + } + + public function testSetCommandRoutesToPrimaryButDoesNotPin(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $clientFd = 42; + + // SET is a transaction-class command, routes to primary + $result = $adapter->classifyQuery($this->buildPgQuery("SET search_path = 'public'"), $clientFd); + $this->assertSame(QueryType::Write, $result); + + // But SET should not pin the connection (only BEGIN/START pin) + $this->assertFalse($adapter->isConnectionPinned($clientFd)); + } + + public function testUnknownQueryRoutesToWrite(): void + { + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + // Use an unknown PG message type + $data = 'X' . \pack('N', 5) . "\x00"; + $result = $adapter->classifyQuery($data, 1); + $this->assertSame(QueryType::Write, $result); + } +} diff --git a/tests/ResolverExtendedTest.php b/tests/ResolverExtendedTest.php new file mode 100644 index 0000000..d9fe696 --- /dev/null +++ b/tests/ResolverExtendedTest.php @@ -0,0 +1,278 @@ +assertTrue($reflection->isReadOnly()); + } + + public function testResultWithEmptyEndpoint(): void + { + $result = new ResolverResult(endpoint: ''); + $this->assertSame('', $result->endpoint); + } + + public function testResultWithLargeMetadata(): void + { + $metadata = []; + for ($i = 0; $i < 100; $i++) { + $metadata["key_{$i}"] = "value_{$i}"; + } + + $result = new ResolverResult(endpoint: 'host:80', metadata: $metadata); + $this->assertCount(100, $result->metadata); + $this->assertSame('value_50', $result->metadata['key_50']); + } + + public function testResultWithZeroTimeout(): void + { + $result = new ResolverResult(endpoint: 'host:80', timeout: 0); + $this->assertSame(0, $result->timeout); + } + + public function testResultWithNegativeTimeout(): void + { + $result = new ResolverResult(endpoint: 'host:80', timeout: -1); + $this->assertSame(-1, $result->timeout); + } + + public function testExceptionNotFound(): void + { + $e = new ResolverException('Not found', ResolverException::NOT_FOUND); + $this->assertSame(404, $e->getCode()); + } + + public function testExceptionUnavailable(): void + { + $e = new ResolverException('Down', ResolverException::UNAVAILABLE); + $this->assertSame(503, $e->getCode()); + } + + public function testExceptionTimeout(): void + { + $e = new ResolverException('Slow', ResolverException::TIMEOUT); + $this->assertSame(504, $e->getCode()); + } + + public function testExceptionForbidden(): void + { + $e = new ResolverException('Denied', ResolverException::FORBIDDEN); + $this->assertSame(403, $e->getCode()); + } + + public function testExceptionInternal(): void + { + $e = new ResolverException('Crash', ResolverException::INTERNAL); + $this->assertSame(500, $e->getCode()); + } + + public function testExceptionIsInstanceOfBaseException(): void + { + $e = new ResolverException('test'); + $this->assertInstanceOf(\Exception::class, $e); + } + + public function testExceptionContextIsReadonly(): void + { + $e = new ResolverException('test', context: ['key' => 'value']); + + $reflection = new \ReflectionProperty($e, 'context'); + $this->assertTrue($reflection->isReadOnly()); + } + + public function testExceptionWithEmptyContext(): void + { + $e = new ResolverException('test'); + $this->assertSame([], $e->context); + } + + public function testExceptionWithRichContext(): void + { + $context = [ + 'resourceId' => 'db-123', + 'attempt' => 3, + 'lastError' => 'connection refused', + 'timestamps' => [1000, 2000, 3000], + ]; + + $e = new ResolverException('Failed after retries', ResolverException::UNAVAILABLE, $context); + + $this->assertSame('db-123', $e->context['resourceId']); + $this->assertSame(3, $e->context['attempt']); + $this->assertSame([1000, 2000, 3000], $e->context['timestamps']); + } + + public function testMockResolverResolvesEndpoint(): void + { + $resolver = new MockResolver(); + $resolver->setEndpoint('backend.db:5432'); + + $result = $resolver->resolve('test-resource'); + + $this->assertSame('backend.db:5432', $result->endpoint); + $this->assertSame('test-resource', $result->metadata['resourceId']); + } + + public function testMockResolverThrowsWhenNoEndpoint(): void + { + $resolver = new MockResolver(); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(404); + + $resolver->resolve('test-resource'); + } + + public function testMockResolverThrowsConfiguredException(): void + { + $resolver = new MockResolver(); + $resolver->setException(new ResolverException('custom error', ResolverException::TIMEOUT)); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('custom error'); + $this->expectExceptionCode(504); + + $resolver->resolve('test-resource'); + } + + public function testMockResolverTracksActivities(): void + { + $resolver = new MockResolver(); + + $resolver->track('resource-1', ['type' => 'query']); + $resolver->track('resource-2', ['type' => 'heartbeat']); + + $activities = $resolver->getActivities(); + $this->assertCount(2, $activities); + $this->assertSame('resource-1', $activities[0]['resourceId']); + $this->assertSame('query', $activities[0]['metadata']['type']); + } + + public function testMockResolverTracksPurges(): void + { + $resolver = new MockResolver(); + + $resolver->purge('resource-1'); + $resolver->purge('resource-2'); + + $invalidations = $resolver->getInvalidations(); + $this->assertCount(2, $invalidations); + $this->assertSame('resource-1', $invalidations[0]); + $this->assertSame('resource-2', $invalidations[1]); + } + + public function testMockResolverResetClearsEverything(): void + { + $resolver = new MockResolver(); + + $resolver->setEndpoint('host:80'); + $resolver->resolve('test'); + $resolver->track('test'); + $resolver->purge('test'); + $resolver->onConnect('test'); + $resolver->onDisconnect('test'); + + $resolver->reset(); + + $this->assertEmpty($resolver->getConnects()); + $this->assertEmpty($resolver->getDisconnects()); + $this->assertEmpty($resolver->getActivities()); + $this->assertEmpty($resolver->getInvalidations()); + } + + public function testMockResolverStats(): void + { + $resolver = new MockResolver(); + + $resolver->onConnect('r1'); + $resolver->onConnect('r2'); + $resolver->onDisconnect('r1'); + $resolver->track('r2'); + + $stats = $resolver->getStats(); + $this->assertSame('mock', $stats['resolver']); + $this->assertSame(2, $stats['connects']); + $this->assertSame(1, $stats['disconnects']); + $this->assertSame(1, $stats['activities']); + } + + public function testMockReadWriteResolverReadEndpoint(): void + { + $resolver = new MockReadWriteResolver(); + $resolver->setReadEndpoint('replica.db:5432'); + + $result = $resolver->resolveRead('test-db'); + + $this->assertSame('replica.db:5432', $result->endpoint); + $this->assertSame('read', $result->metadata['route']); + } + + public function testMockReadWriteResolverWriteEndpoint(): void + { + $resolver = new MockReadWriteResolver(); + $resolver->setWriteEndpoint('primary.db:5432'); + + $result = $resolver->resolveWrite('test-db'); + + $this->assertSame('primary.db:5432', $result->endpoint); + $this->assertSame('write', $result->metadata['route']); + } + + public function testMockReadWriteResolverThrowsNoReadEndpoint(): void + { + $resolver = new MockReadWriteResolver(); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(404); + + $resolver->resolveRead('test-db'); + } + + public function testMockReadWriteResolverThrowsNoWriteEndpoint(): void + { + $resolver = new MockReadWriteResolver(); + + $this->expectException(ResolverException::class); + $this->expectExceptionCode(404); + + $resolver->resolveWrite('test-db'); + } + + public function testMockReadWriteResolverRouteLog(): void + { + $resolver = new MockReadWriteResolver(); + $resolver->setReadEndpoint('replica:5432'); + $resolver->setWriteEndpoint('primary:5432'); + + $resolver->resolveRead('db-1'); + $resolver->resolveWrite('db-2'); + $resolver->resolveRead('db-3'); + + $log = $resolver->getRouteLog(); + $this->assertCount(3, $log); + $this->assertSame('read', $log[0]['type']); + $this->assertSame('write', $log[1]['type']); + $this->assertSame('read', $log[2]['type']); + } + + public function testMockReadWriteResolverResetIncludesRouteLog(): void + { + $resolver = new MockReadWriteResolver(); + $resolver->setReadEndpoint('replica:5432'); + $resolver->resolveRead('db-1'); + + $resolver->reset(); + + $this->assertEmpty($resolver->getRouteLog()); + } +} diff --git a/tests/ResolverTest.php b/tests/ResolverTest.php new file mode 100644 index 0000000..6429f1a --- /dev/null +++ b/tests/ResolverTest.php @@ -0,0 +1,62 @@ + false, 'type' => 'http'], + timeout: 30 + ); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame(['cached' => false, 'type' => 'http'], $result->metadata); + $this->assertSame(30, $result->timeout); + } + + public function testResolverResultDefaultValues(): void + { + $result = new ResolverResult(endpoint: '127.0.0.1:8080'); + + $this->assertSame('127.0.0.1:8080', $result->endpoint); + $this->assertSame([], $result->metadata); + $this->assertNull($result->timeout); + } + + public function testResolverExceptionWithContext(): void + { + $exception = new ResolverException( + 'Resource not found', + ResolverException::NOT_FOUND, + ['resourceId' => 'abc123', 'type' => 'database'] + ); + + $this->assertSame('Resource not found', $exception->getMessage()); + $this->assertSame(404, $exception->getCode()); + $this->assertSame(['resourceId' => 'abc123', 'type' => 'database'], $exception->context); + } + + public function testResolverExceptionErrorCodes(): void + { + $this->assertSame(404, ResolverException::NOT_FOUND); + $this->assertSame(503, ResolverException::UNAVAILABLE); + $this->assertSame(504, ResolverException::TIMEOUT); + $this->assertSame(403, ResolverException::FORBIDDEN); + $this->assertSame(500, ResolverException::INTERNAL); + } + + public function testResolverExceptionDefaultCode(): void + { + $exception = new ResolverException('Internal error'); + + $this->assertSame(500, $exception->getCode()); + $this->assertSame([], $exception->context); + } +} diff --git a/tests/RoutingCacheTest.php b/tests/RoutingCacheTest.php new file mode 100644 index 0000000..8e23dc6 --- /dev/null +++ b/tests/RoutingCacheTest.php @@ -0,0 +1,207 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testFirstCallIsCacheMiss(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + // Ensure we're at the start of a clean second + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $result = $adapter->route('resource-1'); + + $this->assertFalse($result->metadata['cached']); + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + } + + public function testSecondCallWithinOneSecondIsCacheHit(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $first = $adapter->route('resource-1'); + $second = $adapter->route('resource-1'); + + $this->assertFalse($first->metadata['cached']); + $this->assertTrue($second->metadata['cached']); + } + + public function testCacheExpiresAfterOneSecond(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + + // Wait for cache to expire + sleep(1); + + $result = $adapter->route('resource-1'); + $this->assertFalse($result->metadata['cached']); + + $stats = $adapter->getStats(); + $this->assertSame(2, $stats['cacheMisses']); + } + + public function testMultipleResourcesCachedIndependently(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + $adapter->route('resource-2'); + + $stats = $adapter->getStats(); + $this->assertSame(2, $stats['cacheMisses']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame(2, $stats['routingTableSize']); + } + + public function testCacheHitPreservesProtocol(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::SMTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + $cached = $adapter->route('resource-1'); + + $this->assertSame(Protocol::SMTP, $cached->protocol); + } + + public function testCacheHitPreservesEndpoint(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + $adapter->route('resource-1'); + $cached = $adapter->route('resource-1'); + + $this->assertSame('8.8.8.8:80', $cached->endpoint); + } + + public function testInitialStatsAreZero(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + + $stats = $adapter->getStats(); + + $this->assertSame(0, $stats['connections']); + $this->assertSame(0, $stats['cacheHits']); + $this->assertSame(0, $stats['cacheMisses']); + $this->assertSame(0, $stats['routingErrors']); + $this->assertSame(0, $stats['cacheHitRate']); + $this->assertSame(0, $stats['routingTableSize']); + } + + public function testStatsContainAdapterInfo(): void + { + $adapter = new Adapter($this->resolver, name: 'MyProxy', protocol: Protocol::HTTP); + + $stats = $adapter->getStats(); + + $this->assertSame('MyProxy', $stats['adapter']); + $this->assertSame('http', $stats['protocol']); + } + + public function testStatsRoutingTableMemoryIsPositive(): void + { + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + + $stats = $adapter->getStats(); + $this->assertGreaterThan(0, $stats['routingTableMemory']); + } + + public function testCacheHitRateCalculation(): void + { + $this->resolver->setEndpoint('8.8.8.8:80'); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + $adapter->setSkipValidation(true); + + $start = time(); + while (time() === $start) { + usleep(1000); + } + + // 1 miss, then 3 hits = 75% hit rate + $adapter->route('resource-1'); + $adapter->route('resource-1'); + $adapter->route('resource-1'); + $adapter->route('resource-1'); + + $stats = $adapter->getStats(); + $this->assertSame(75.0, $stats['cacheHitRate']); + } + + public function testMultipleErrorsIncrementStats(): void + { + $this->resolver->setException(new \Utopia\Proxy\Resolver\Exception('fail')); + $adapter = new Adapter($this->resolver, name: 'Test', protocol: Protocol::HTTP); + + for ($i = 0; $i < 3; $i++) { + try { + $adapter->route('resource-1'); + } catch (\Exception $e) { + // expected + } + } + + $stats = $adapter->getStats(); + $this->assertSame(3, $stats['routingErrors']); + $this->assertSame(3, $stats['cacheMisses']); + } +} diff --git a/tests/TCPAdapterExtendedTest.php b/tests/TCPAdapterExtendedTest.php new file mode 100644 index 0000000..cbe2797 --- /dev/null +++ b/tests/TCPAdapterExtendedTest.php @@ -0,0 +1,575 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + $this->rwResolver = new MockReadWriteResolver(); + } + + public function testProtocolForPostgresPort(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $this->assertSame(Protocol::PostgreSQL, $adapter->getProtocol()); + } + + public function testProtocolForMysqlPort(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + $this->assertSame(Protocol::MySQL, $adapter->getProtocol()); + } + + public function testProtocolForMongoPort(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + $this->assertSame(Protocol::MongoDB, $adapter->getProtocol()); + } + + public function testProtocolThrowsForUnsupportedPort(): void + { + $adapter = new TCPAdapter($this->resolver, port: 8080); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Unsupported protocol on port: 8080'); + + $adapter->getProtocol(); + } + + public function testPortProperty(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $this->assertSame(5432, $adapter->port); + } + + public function testNameIsAlwaysTCP(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $this->assertSame('TCP', $adapter->getName()); + } + + public function testDescription(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $this->assertStringContainsString('PostgreSQL', $adapter->getDescription()); + $this->assertStringContainsString('MySQL', $adapter->getDescription()); + $this->assertStringContainsString('MongoDB', $adapter->getDescription()); + } + + public function testSetConnectTimeoutReturnsSelf(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $result = $adapter->setConnectTimeout(10.0); + $this->assertSame($adapter, $result); + } + + public function testSetReadWriteSplitReturnsSelf(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $result = $adapter->setReadWriteSplit(true); + $this->assertSame($adapter, $result); + } + + public function testPostgresParseAlphanumericId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $data = "user\x00appwrite\x00database\x00db-ABCdef789\x00"; + + $this->assertSame('ABCdef789', $adapter->parseDatabaseId($data, 1)); + } + + public function testPostgresParseIdWithDotSuffix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $data = "user\x00appwrite\x00database\x00db-abc123.us-east-1.example.com\x00"; + + // Parsing stops at the dot + $this->assertSame('abc123', $adapter->parseDatabaseId($data, 1)); + } + + public function testPostgresParseIdWithLeadingFields(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + // Extra key-value pairs before "database" + $data = "user\x00admin\x00options\x00-c\x00database\x00db-xyz\x00\x00"; + + $this->assertSame('xyz', $adapter->parseDatabaseId($data, 1)); + } + + public function testPostgresRejectsMissingDatabaseMarker(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId("user\x00appwrite\x00", 1); + } + + public function testPostgresRejectsMissingNullTerminator(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + // No null byte after the database name + $adapter->parseDatabaseId("database\x00db-abc123", 1); + } + + public function testPostgresRejectsNonDbPrefix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId("database\x00mydb\x00", 1); + } + + public function testPostgresRejectsEmptyIdAfterDbPrefix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId("database\x00db-\x00", 1); + } + + public function testPostgresRejectsSpecialCharactersInId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId("database\x00db-abc@123\x00", 1); + } + + public function testPostgresRejectsHyphenInId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId("database\x00db-abc-123\x00", 1); + } + + public function testPostgresRejectsUnderscoreInId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId("database\x00db-abc_123\x00", 1); + } + + public function testPostgresParsesSingleCharId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $data = "database\x00db-x\x00"; + + $this->assertSame('x', $adapter->parseDatabaseId($data, 1)); + } + + public function testPostgresParsesNumericOnlyId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $data = "database\x00db-123456\x00"; + + $this->assertSame('123456', $adapter->parseDatabaseId($data, 1)); + } + + public function testMysqlParseAlphanumericId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + $data = "\x00\x00\x00\x00\x02db-ABCdef789"; + + $this->assertSame('ABCdef789', $adapter->parseDatabaseId($data, 1)); + } + + public function testMysqlParseIdWithNullTerminator(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + $data = "\x00\x00\x00\x00\x02db-abc123\x00extra"; + + $this->assertSame('abc123', $adapter->parseDatabaseId($data, 1)); + } + + public function testMysqlParseIdWithDotSuffix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + $data = "\x00\x00\x00\x00\x02db-abc123.us-east-1"; + + $this->assertSame('abc123', $adapter->parseDatabaseId($data, 1)); + } + + public function testMysqlRejectsTooShortPacket(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + $adapter->parseDatabaseId("\x00\x00\x00\x00\x02", 1); + } + + public function testMysqlRejectsWrongCommandByte(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + // Command byte 0x03 instead of 0x02 + $adapter->parseDatabaseId("\x00\x00\x00\x00\x03db-abc123", 1); + } + + public function testMysqlRejectsNonDbPrefix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + $adapter->parseDatabaseId("\x00\x00\x00\x00\x02mydb", 1); + } + + public function testMysqlRejectsEmptyIdAfterDbPrefix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + $adapter->parseDatabaseId("\x00\x00\x00\x00\x02db-", 1); + } + + public function testMysqlRejectsSpecialCharactersInId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + $adapter->parseDatabaseId("\x00\x00\x00\x00\x02db-abc!123", 1); + } + + public function testMysqlRejectsEmptyPacket(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + $adapter->parseDatabaseId('', 1); + } + + public function testMongoParsesDatabaseId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + // Build a MongoDB OP_MSG-like packet with $db field + // "$db\0" marker followed by BSON string length (little-endian) and the string + $dbName = "db-abc123\x00"; // null-terminated + $strLen = pack('V', strlen($dbName)); // 10 as 4 bytes LE + $data = str_repeat("\x00", 21) . "\$db\x00" . $strLen . $dbName; + + $this->assertSame('abc123', $adapter->parseDatabaseId($data, 1)); + } + + public function testMongoParsesIdWithDotSuffix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + $dbName = "db-xyz789.collection\x00"; + $strLen = pack('V', strlen($dbName)); + $data = str_repeat("\x00", 21) . "\$db\x00" . $strLen . $dbName; + + $this->assertSame('xyz789', $adapter->parseDatabaseId($data, 1)); + } + + public function testMongoRejectsMissingDbMarker(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MongoDB database name'); + + $adapter->parseDatabaseId(str_repeat("\x00", 50), 1); + } + + public function testMongoRejectsNonDbPrefix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + $dbName = "mydb\x00"; + $strLen = pack('V', strlen($dbName)); + $data = "\$db\x00" . $strLen . $dbName; + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MongoDB database name'); + + $adapter->parseDatabaseId($data, 1); + } + + public function testMongoRejectsEmptyIdAfterDbPrefix(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + $dbName = "db-\x00"; + $strLen = pack('V', strlen($dbName)); + $data = "\$db\x00" . $strLen . $dbName; + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MongoDB database name'); + + $adapter->parseDatabaseId($data, 1); + } + + public function testMongoRejectsTruncatedData(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + // "$db\0" marker but not enough bytes for the string length + $data = "\$db\x00\x0A"; + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MongoDB database name'); + + $adapter->parseDatabaseId($data, 1); + } + + public function testMongoRejectsSpecialCharactersInId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + $dbName = "db-abc@123\x00"; + $strLen = pack('V', strlen($dbName)); + $data = "\$db\x00" . $strLen . $dbName; + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MongoDB database name'); + + $adapter->parseDatabaseId($data, 1); + } + + public function testMongoParsesAlphanumericId(): void + { + $adapter = new TCPAdapter($this->resolver, port: 27017); + + $dbName = "db-ABCdef789\x00"; + $strLen = pack('V', strlen($dbName)); + $data = "\$db\x00" . $strLen . $dbName; + + $this->assertSame('ABCdef789', $adapter->parseDatabaseId($data, 1)); + } + + public function testClearConnectionStateForNonExistentFd(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $adapter->setReadWriteSplit(true); + + // Should not throw + $adapter->clearConnectionState(999); + $this->assertFalse($adapter->isConnectionPinned(999)); + } + + public function testIsConnectionPinnedDefaultFalse(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $this->assertFalse($adapter->isConnectionPinned(1)); + } + + public function testRouteQueryReadThrowsWhenNoReadEndpoint(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + $this->rwResolver->setWriteEndpoint('primary.db:5432'); + // No read endpoint set + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + + $adapter->routeQuery('test-db', QueryType::Read); + } + + public function testRouteQueryWriteThrowsWhenNoWriteEndpoint(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + $this->rwResolver->setReadEndpoint('replica.db:5432'); + // No write endpoint set + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + + $adapter->routeQuery('test-db', QueryType::Write); + } + + public function testRouteQueryReadEmptyEndpointThrows(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + $this->rwResolver->setReadEndpoint(''); + $this->rwResolver->setWriteEndpoint('primary.db:5432'); + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('empty read endpoint'); + + $adapter->routeQuery('test-db', QueryType::Read); + } + + public function testRouteQueryWriteEmptyEndpointThrows(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + $this->rwResolver->setReadEndpoint('replica.db:5432'); + $this->rwResolver->setWriteEndpoint(''); + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('empty write endpoint'); + + $adapter->routeQuery('test-db', QueryType::Write); + } + + public function testRouteQueryReadIncrementsErrorStatsOnFailure(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + // No read endpoint β€” will throw + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + try { + $adapter->routeQuery('test-db', QueryType::Read); + $this->fail('Expected exception'); + } catch (ResolverException $e) { + // expected + } + + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['routingErrors']); + } + + public function testRouteQueryWriteIncrementsErrorStatsOnFailure(): void + { + $this->rwResolver->setEndpoint('primary.db:5432'); + // No write endpoint β€” will throw + + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + try { + $adapter->routeQuery('test-db', QueryType::Write); + $this->fail('Expected exception'); + } catch (ResolverException $e) { + // expected + } + + $stats = $adapter->getStats(); + $this->assertSame(1, $stats['routingErrors']); + } + + public function testRouteQueryReadMetadataIncludesRouteType(): void + { + $this->rwResolver->setReadEndpoint('replica.db:5432'); + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Read); + $this->assertSame('read', $result->metadata['route']); + $this->assertFalse($result->metadata['cached']); + } + + public function testRouteQueryWriteMetadataIncludesRouteType(): void + { + $this->rwResolver->setWriteEndpoint('primary.db:5432'); + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Write); + $this->assertSame('write', $result->metadata['route']); + $this->assertFalse($result->metadata['cached']); + } + + public function testRouteQueryReadPreservesResolverMetadata(): void + { + $this->rwResolver->setReadEndpoint('replica.db:5432'); + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Read); + $this->assertSame('test-db', $result->metadata['resourceId']); + } + + public function testRouteQueryReadValidatesEndpoint(): void + { + $this->rwResolver->setReadEndpoint('10.0.0.1:5432'); + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + // Validation is ON (default) + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->routeQuery('test-db', QueryType::Read); + } + + public function testRouteQueryWriteValidatesEndpoint(): void + { + $this->rwResolver->setWriteEndpoint('192.168.1.1:5432'); + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + + $this->expectException(ResolverException::class); + $this->expectExceptionMessage('private/reserved IP'); + + $adapter->routeQuery('test-db', QueryType::Write); + } + + public function testRouteQuerySkipsValidationWhenDisabled(): void + { + $this->rwResolver->setReadEndpoint('10.0.0.1:5432'); + $adapter = new TCPAdapter($this->rwResolver, port: 5432); + $adapter->setReadWriteSplit(true); + $adapter->setSkipValidation(true); + + $result = $adapter->routeQuery('test-db', QueryType::Read); + $this->assertSame('10.0.0.1:5432', $result->endpoint); + } +} diff --git a/tests/TCPAdapterTest.php b/tests/TCPAdapterTest.php new file mode 100644 index 0000000..7ba36af --- /dev/null +++ b/tests/TCPAdapterTest.php @@ -0,0 +1,59 @@ +markTestSkipped('ext-swoole is required to run adapter tests.'); + } + + $this->resolver = new MockResolver(); + } + + public function testPostgresDatabaseIdParsing(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + $data = "user\x00appwrite\x00database\x00db-abc123\x00"; + + $this->assertSame('abc123', $adapter->parseDatabaseId($data, 1)); + $this->assertSame(Protocol::PostgreSQL, $adapter->getProtocol()); + } + + public function testMySqlDatabaseIdParsing(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + $data = "\x00\x00\x00\x00\x02db-xyz789"; + + $this->assertSame('xyz789', $adapter->parseDatabaseId($data, 1)); + $this->assertSame(Protocol::MySQL, $adapter->getProtocol()); + } + + public function testPostgresDatabaseIdParsingFailsOnInvalidData(): void + { + $adapter = new TCPAdapter($this->resolver, port: 5432); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid PostgreSQL database name'); + + $adapter->parseDatabaseId('invalid', 1); + } + + public function testMySqlDatabaseIdParsingFailsOnInvalidData(): void + { + $adapter = new TCPAdapter($this->resolver, port: 3306); + + $this->expectException(\Exception::class); + $this->expectExceptionMessage('Invalid MySQL database name'); + + $adapter->parseDatabaseId("\x00\x00\x00\x00\x01db-xyz", 1); + } +} diff --git a/tests/TLSTest.php b/tests/TLSTest.php new file mode 100644 index 0000000..9731558 --- /dev/null +++ b/tests/TLSTest.php @@ -0,0 +1,346 @@ +markTestSkipped('ext-swoole is required to run TLS tests.'); + } + } + + public function testConstructorSetsRequiredPaths(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + + $this->assertSame('/certs/server.crt', $tls->certPath); + $this->assertSame('/certs/server.key', $tls->keyPath); + } + + public function testConstructorDefaultValues(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + + $this->assertSame('', $tls->caPath); + $this->assertFalse($tls->requireClientCert); + $this->assertSame(TLS::DEFAULT_CIPHERS, $tls->ciphers); + $this->assertSame(TLS::MIN_TLS_VERSION, $tls->minProtocol); + } + + public function testConstructorCustomValues(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + requireClientCert: true, + ciphers: 'ECDHE-RSA-AES128-GCM-SHA256', + minProtocol: SWOOLE_SSL_TLSv1_3, + ); + + $this->assertSame('/certs/ca.crt', $tls->caPath); + $this->assertTrue($tls->requireClientCert); + $this->assertSame('ECDHE-RSA-AES128-GCM-SHA256', $tls->ciphers); + $this->assertSame(SWOOLE_SSL_TLSv1_3, $tls->minProtocol); + } + + public function testPgSslRequestConstant(): void + { + $this->assertSame(8, strlen(TLS::PG_SSL_REQUEST)); + // Verify SSL request code bytes: 0x04D2162F = 80877103 + $this->assertSame("\x00\x00\x00\x08\x04\xd2\x16\x2f", TLS::PG_SSL_REQUEST); + } + + public function testPgSslResponseConstants(): void + { + $this->assertSame('S', TLS::PG_SSL_RESPONSE_OK); + $this->assertSame('N', TLS::PG_SSL_RESPONSE_REJECT); + } + + public function testMySqlSslFlagConstant(): void + { + $this->assertSame(0x00000800, TLS::MYSQL_CLIENT_SSL_FLAG); + } + + public function testDefaultCiphersContainsModernSuites(): void + { + $this->assertStringContainsString('ECDHE-ECDSA-AES128-GCM-SHA256', TLS::DEFAULT_CIPHERS); + $this->assertStringContainsString('ECDHE-RSA-AES256-GCM-SHA384', TLS::DEFAULT_CIPHERS); + $this->assertStringContainsString('CHACHA20-POLY1305', TLS::DEFAULT_CIPHERS); + } + + public function testValidatePassesWithReadableFiles(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + $tls = new TLS(certPath: $certFile, keyPath: $keyFile); + $tls->validate(); + $this->addToAssertionCount(1); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testValidateThrowsForUnreadableCert(): void + { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TLS certificate file not readable'); + + $tls = new TLS(certPath: '/nonexistent/cert.crt', keyPath: '/tmp/key.key'); + $tls->validate(); + } + + public function testValidateThrowsForUnreadableKey(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TLS private key file not readable'); + + $tls = new TLS(certPath: $certFile, keyPath: '/nonexistent/key.key'); + $tls->validate(); + } finally { + unlink($certFile); + } + } + + public function testValidateThrowsWhenClientCertRequiredButNoCaPath(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('CA certificate path is required when client certificate verification is enabled'); + + $tls = new TLS( + certPath: $certFile, + keyPath: $keyFile, + requireClientCert: true, + ); + $tls->validate(); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testValidateThrowsForUnreadableCaFile(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('TLS CA certificate file not readable'); + + $tls = new TLS( + certPath: $certFile, + keyPath: $keyFile, + caPath: '/nonexistent/ca.crt', + ); + $tls->validate(); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testValidatePassesWithAllReadableFiles(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + $caFile = tempnam(sys_get_temp_dir(), 'ca_'); + + try { + $tls = new TLS( + certPath: $certFile, + keyPath: $keyFile, + caPath: $caFile, + requireClientCert: true, + ); + $tls->validate(); + $this->addToAssertionCount(1); + } finally { + unlink($certFile); + unlink($keyFile); + unlink($caFile); + } + } + + public function testValidateCaPathOptionalWithoutClientCert(): void + { + $certFile = tempnam(sys_get_temp_dir(), 'cert_'); + $keyFile = tempnam(sys_get_temp_dir(), 'key_'); + + try { + // caPath is empty and requireClientCert is false β€” should pass + $tls = new TLS(certPath: $certFile, keyPath: $keyFile); + $tls->validate(); + $this->addToAssertionCount(1); + } finally { + unlink($certFile); + unlink($keyFile); + } + } + + public function testIsMutualTLSReturnsTrueWhenBothConditionsMet(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + requireClientCert: true, + ); + + $this->assertTrue($tls->isMutualTLS()); + } + + public function testIsMutualTLSReturnsFalseWhenClientCertNotRequired(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + requireClientCert: false, + ); + + $this->assertFalse($tls->isMutualTLS()); + } + + public function testIsMutualTLSReturnsFalseWhenCaPathEmpty(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + requireClientCert: true, + ); + + $this->assertFalse($tls->isMutualTLS()); + } + + public function testIsMutualTLSReturnsFalseWithDefaults(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + + $this->assertFalse($tls->isMutualTLS()); + } + + public function testIsPostgreSQLSSLRequestWithValidData(): void + { + $this->assertTrue(TLS::isPostgreSQLSSLRequest(TLS::PG_SSL_REQUEST)); + } + + public function testIsPostgreSQLSSLRequestWithTooShortData(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest("\x00\x00\x00\x08\x04\xd2\x16")); + } + + public function testIsPostgreSQLSSLRequestWithTooLongData(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest(TLS::PG_SSL_REQUEST . "\x00")); + } + + public function testIsPostgreSQLSSLRequestWithEmptyData(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest('')); + } + + public function testIsPostgreSQLSSLRequestWithWrongBytes(): void + { + $this->assertFalse(TLS::isPostgreSQLSSLRequest("\x00\x00\x00\x08\x00\x00\x00\x00")); + } + + public function testIsPostgreSQLSSLRequestWithRegularStartupMessage(): void + { + // A regular PostgreSQL startup message (protocol version 3.0) + $startup = "\x00\x00\x00\x08\x00\x03\x00\x00"; + $this->assertFalse(TLS::isPostgreSQLSSLRequest($startup)); + } + + public function testIsMySQLSSLRequestWithValidData(): void + { + // Build a valid MySQL SSL request: 36+ bytes, sequence ID 1, SSL flag set + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; // sequence ID = 1 + // Set CLIENT_SSL flag (0x0800) at offset 4-5 (little-endian) + $data[4] = "\x00"; + $data[5] = "\x08"; // 0x0800 in little-endian + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithTooShortData(): void + { + $this->assertFalse(TLS::isMySQLSSLRequest(str_repeat("\x00", 35))); + } + + public function testIsMySQLSSLRequestWithEmptyData(): void + { + $this->assertFalse(TLS::isMySQLSSLRequest('')); + } + + public function testIsMySQLSSLRequestWithWrongSequenceId(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x02"; // sequence ID = 2 (should be 1) + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertFalse(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithoutSslFlag(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; // sequence ID = 1 + // No SSL flag + $data[4] = "\x00"; + $data[5] = "\x00"; + $this->assertFalse(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithSslFlagAndOtherFlags(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; // sequence ID = 1 + // SSL flag (0x0800) combined with other flags (0xFF) + $data[4] = "\xFF"; + $data[5] = "\x0F"; // includes 0x0800 + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithSequenceIdZero(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x00"; // sequence ID = 0 + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertFalse(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithExactly36Bytes(): void + { + $data = str_repeat("\x00", 36); + $data[3] = "\x01"; + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } + + public function testIsMySQLSSLRequestWithLargerPacket(): void + { + $data = str_repeat("\x00", 100); + $data[3] = "\x01"; + $data[4] = "\x00"; + $data[5] = "\x08"; + $this->assertTrue(TLS::isMySQLSSLRequest($data)); + } +} diff --git a/tests/TlsContextTest.php b/tests/TlsContextTest.php new file mode 100644 index 0000000..720d8cd --- /dev/null +++ b/tests/TlsContextTest.php @@ -0,0 +1,182 @@ +markTestSkipped('ext-swoole is required to run TlsContext tests.'); + } + } + + public function testToSwooleConfigBasic(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $ctx = new TlsContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame('/certs/server.crt', $config['ssl_cert_file']); + $this->assertSame('/certs/server.key', $config['ssl_key_file']); + $this->assertSame(TLS::DEFAULT_CIPHERS, $config['ssl_ciphers']); + $this->assertSame(TLS::MIN_TLS_VERSION, $config['ssl_protocols']); + $this->assertFalse($config['ssl_allow_self_signed']); + $this->assertFalse($config['ssl_verify_peer']); + $this->assertArrayNotHasKey('ssl_client_cert_file', $config); + $this->assertArrayNotHasKey('ssl_verify_depth', $config); + } + + public function testToSwooleConfigWithCaPath(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + ); + $ctx = new TlsContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame('/certs/ca.crt', $config['ssl_client_cert_file']); + $this->assertFalse($config['ssl_verify_peer']); + } + + public function testToSwooleConfigWithMutualTLS(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + requireClientCert: true, + ); + $ctx = new TlsContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame('/certs/ca.crt', $config['ssl_client_cert_file']); + $this->assertTrue($config['ssl_verify_peer']); + $this->assertSame(10, $config['ssl_verify_depth']); + } + + public function testToSwooleConfigWithCustomCiphers(): void + { + $customCiphers = 'ECDHE-RSA-AES128-GCM-SHA256'; + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + ciphers: $customCiphers, + ); + $ctx = new TlsContext($tls); + + $config = $ctx->toSwooleConfig(); + + $this->assertSame($customCiphers, $config['ssl_ciphers']); + } + + public function testToStreamContextReturnsResource(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $ctx = new TlsContext($tls); + + $streamCtx = $ctx->toStreamContext(); + + $this->assertSame('stream-context', get_resource_type($streamCtx)); + } + + public function testToStreamContextHasCorrectSslOptions(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $ctx = new TlsContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + + $this->assertArrayHasKey('ssl', $options); + /** @var array $ssl */ + $ssl = $options['ssl']; + $this->assertSame('/certs/server.crt', $ssl['local_cert']); + $this->assertSame('/certs/server.key', $ssl['local_pk']); + $this->assertTrue($ssl['disable_compression']); + $this->assertFalse($ssl['allow_self_signed']); + $this->assertFalse($ssl['verify_peer']); + $this->assertFalse($ssl['verify_peer_name']); + } + + public function testToStreamContextWithCaFile(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + ); + $ctx = new TlsContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + /** @var array $ssl */ + $ssl = $options['ssl']; + + $this->assertSame('/certs/ca.crt', $ssl['cafile']); + } + + public function testToStreamContextWithMutualTLS(): void + { + $tls = new TLS( + certPath: '/certs/server.crt', + keyPath: '/certs/server.key', + caPath: '/certs/ca.crt', + requireClientCert: true, + ); + $ctx = new TlsContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + /** @var array $ssl */ + $ssl = $options['ssl']; + + $this->assertTrue($ssl['verify_peer']); + $this->assertFalse($ssl['verify_peer_name']); + $this->assertSame(10, $ssl['verify_depth']); + } + + public function testToStreamContextWithoutCaFile(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $ctx = new TlsContext($tls); + + $streamCtx = $ctx->toStreamContext(); + /** @var array> $options */ + $options = stream_context_get_options($streamCtx); + /** @var array $ssl */ + $ssl = $options['ssl']; + + $this->assertArrayNotHasKey('cafile', $ssl); + } + + public function testGetSocketTypeIncludesSslFlag(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $ctx = new TlsContext($tls); + + $socketType = $ctx->getSocketType(); + + $this->assertSame(SWOOLE_SOCK_TCP | SWOOLE_SSL, $socketType); + } + + public function testGetTlsReturnsOriginalInstance(): void + { + $tls = new TLS(certPath: '/certs/server.crt', keyPath: '/certs/server.key'); + $ctx = new TlsContext($tls); + + $this->assertSame($tls, $ctx->getTls()); + } +}