diff --git a/frameworks/symfony-spawn-tas/.env b/frameworks/symfony-spawn-tas/.env new file mode 100644 index 000000000..2b8a6747a --- /dev/null +++ b/frameworks/symfony-spawn-tas/.env @@ -0,0 +1,5 @@ +APP_ENV=prod +APP_DEBUG=0 +APP_SECRET=benchmark_httparena_secret +DATABASE_URL=pgsql://bench:bench@localhost:5432/benchmark +DEFAULT_URI=http://localhost diff --git a/frameworks/symfony-spawn-tas/.gitignore b/frameworks/symfony-spawn-tas/.gitignore new file mode 100644 index 000000000..c37827d6a --- /dev/null +++ b/frameworks/symfony-spawn-tas/.gitignore @@ -0,0 +1,9 @@ +/test/* +/var/ +/vendor/ +/.env.local +/.env.local.php +/.env.*.local +/public/bundles/ +/public/build/ +/node_modules/ diff --git a/frameworks/symfony-spawn-tas/Dockerfile b/frameworks/symfony-spawn-tas/Dockerfile new file mode 100644 index 000000000..a5c9f76e9 --- /dev/null +++ b/frameworks/symfony-spawn-tas/Dockerfile @@ -0,0 +1,32 @@ +FROM trueasync/php-true-async:0.7.0-alpha.14-php8.6-alpine + +RUN apk add --no-cache git openssh-client + +RUN printf '%s\n' \ + 'opcache.jit=1255' \ + 'opcache.jit_buffer_size=128M' \ + 'opcache.memory_consumption=256' \ + 'opcache.max_accelerated_files=10000' \ + 'opcache.validate_timestamps=0' \ + 'memory_limit=1024M' \ + > /etc/php.d/99-arena.ini + +# Install composer +COPY --from=composer:latest /usr/bin/composer /usr/local/bin/composer + +WORKDIR /app + +COPY composer.json ./ + +RUN APP_ENV=prod COMPOSER_MAX_PARALLEL_HTTP=1 composer install --no-dev --optimize-autoloader --no-scripts --no-interaction + +COPY . . + +RUN APP_ENV=prod APP_DEBUG=0 APP_SECRET=benchmark \ + DATABASE_URL=pgsql://bench:bench@localhost:5432/benchmark \ + DEFAULT_URI=http://localhost \ + php bin/console cache:warmup || true + +EXPOSE 8080 8443 + +CMD ["php", "/app/public/index.php"] diff --git a/frameworks/symfony-spawn-tas/composer.json b/frameworks/symfony-spawn-tas/composer.json new file mode 100644 index 000000000..eef16ad0b --- /dev/null +++ b/frameworks/symfony-spawn-tas/composer.json @@ -0,0 +1,88 @@ +{ + "type": "project", + "license": "MIT", + "minimum-stability": "dev", + "prefer-stable": true, + "require": { + "php": ">=8.6", + "ext-ctype": "*", + "ext-iconv": "*", + "ext-pcntl": "*", + "ext-pdo": "*", + "doctrine/dbal": "^4.4", + "doctrine/doctrine-bundle": "^2.18", + "symfony/console": "7.4.*", + "symfony/dotenv": "7.4.*", + "symfony/flex": "^2", + "symfony/framework-bundle": "7.4.*", + "symfony/runtime": "7.4.*", + "symfony/yaml": "7.4.*", + "yangusik/symfony-spawn": "*" + }, + "config": { + "allow-plugins": { + "php-http/discovery": true, + "symfony/flex": true, + "symfony/runtime": true + }, + "sort-packages": true + }, + "autoload": { + "psr-4": { + "App\\": "src/" + } + }, + "replace": { + "symfony/polyfill-ctype": "*", + "symfony/polyfill-iconv": "*", + "symfony/polyfill-php72": "*", + "symfony/polyfill-php73": "*", + "symfony/polyfill-php74": "*", + "symfony/polyfill-php80": "*", + "symfony/polyfill-php81": "*", + "symfony/polyfill-php82": "*" + }, + "scripts": { + "auto-scripts": { + "cache:clear": "symfony-cmd" + }, + "post-install-cmd": [ + "@auto-scripts" + ], + "post-update-cmd": [ + "@auto-scripts" + ] + }, + "conflict": { + "symfony/symfony": "*" + }, + "extra": { + "symfony": { + "allow-contrib": false, + "require": "7.4.*" + }, + "runtime": { + "class": "Spawn\\Symfony\\Runtime\\TrueAsyncRuntime", + "options": { + "listeners": [ + {"host": "0.0.0.0", "port": 8080, "tls": false}, + {"host": "0.0.0.0", "port": 8081, "tls": true, "protocol": "http1"}, + {"host": "0.0.0.0", "port": 8443, "tls": true, "protocol": "auto"} + ], + "static_handlers": [ + { + "prefix": "/static/", + "root": "/data/static", + "precompressed": ["br", "gzip"], + "etag": true, + "open_file_cache": [1024, 60], + "on_missing": "next" + } + ], + "workers": 0, + "compression": true, + "max_body_size": 33554432 + } + } + } +} diff --git a/frameworks/symfony-spawn-tas/config/bundles.php b/frameworks/symfony-spawn-tas/config/bundles.php new file mode 100644 index 000000000..7ee07cc31 --- /dev/null +++ b/frameworks/symfony-spawn-tas/config/bundles.php @@ -0,0 +1,7 @@ + ['all' => true], + Doctrine\Bundle\DoctrineBundle\DoctrineBundle::class => ['all' => true], + Spawn\Symfony\TrueAsyncBundle::class => ['all' => true], +]; diff --git a/frameworks/symfony-spawn-tas/config/packages/doctrine.yaml b/frameworks/symfony-spawn-tas/config/packages/doctrine.yaml new file mode 100644 index 000000000..86df03148 --- /dev/null +++ b/frameworks/symfony-spawn-tas/config/packages/doctrine.yaml @@ -0,0 +1,7 @@ +doctrine: + dbal: + driver_class: Spawn\Symfony\Database\TrueAsyncPgsqlDriver + url: '%env(DATABASE_URL)%' + server_version: '17' + use_savepoints: true + diff --git a/frameworks/symfony-spawn-tas/config/packages/framework.yaml b/frameworks/symfony-spawn-tas/config/packages/framework.yaml new file mode 100644 index 000000000..bafc204cf --- /dev/null +++ b/frameworks/symfony-spawn-tas/config/packages/framework.yaml @@ -0,0 +1,6 @@ +framework: + secret: '%env(APP_SECRET)%' + http_method_override: false + handle_all_throwables: true + php_errors: + log: true diff --git a/frameworks/symfony-spawn-tas/config/packages/true_async.yaml b/frameworks/symfony-spawn-tas/config/packages/true_async.yaml new file mode 100644 index 000000000..c547c2ec0 --- /dev/null +++ b/frameworks/symfony-spawn-tas/config/packages/true_async.yaml @@ -0,0 +1,6 @@ +true_async: + db_pool: + enabled: true + min: 4 + max: 64 + healthcheck_interval: 30 diff --git a/frameworks/symfony-spawn-tas/config/routes.yaml b/frameworks/symfony-spawn-tas/config/routes.yaml new file mode 100644 index 000000000..41ef8140b --- /dev/null +++ b/frameworks/symfony-spawn-tas/config/routes.yaml @@ -0,0 +1,5 @@ +controllers: + resource: + path: ../src/Controller/ + namespace: App\Controller + type: attribute diff --git a/frameworks/symfony-spawn-tas/config/services.yaml b/frameworks/symfony-spawn-tas/config/services.yaml new file mode 100644 index 000000000..1972e6531 --- /dev/null +++ b/frameworks/symfony-spawn-tas/config/services.yaml @@ -0,0 +1,7 @@ +services: + _defaults: + autowire: true + autoconfigure: true + + App\: + resource: '../src/' diff --git a/frameworks/symfony-spawn-tas/meta.json b/frameworks/symfony-spawn-tas/meta.json new file mode 100644 index 000000000..f7c1126ac --- /dev/null +++ b/frameworks/symfony-spawn-tas/meta.json @@ -0,0 +1,25 @@ +{ + "display_name": "symfony-spawn-tas", + "language": "PHP", + "type": "tuned", + "engine": "C", + "description": "Symfony with symfony-spawn bundle: coroutine-per-request isolation via TrueAsync PHP core, Doctrine DBAL connection pooling, and TrueAsyncServer.", + "repo": "https://github.com/yangusik/symfony-spawn", + "enabled": true, + "tests": [ + "baseline", + "pipelined", + "limited-conn", + "json", + "json-comp", + "json-tls", + "upload", + "static", + "static-h2", + "baseline-h2", + "async-db" + ], + "maintainers": [ + "YanGusik" + ] +} diff --git a/frameworks/symfony-spawn-tas/public/index.php b/frameworks/symfony-spawn-tas/public/index.php new file mode 100644 index 000000000..c0037a8db --- /dev/null +++ b/frameworks/symfony-spawn-tas/public/index.php @@ -0,0 +1,9 @@ + 'text/css', + 'js' => 'application/javascript', + 'html' => 'text/html', + 'woff2' => 'font/woff2', + 'svg' => 'image/svg+xml', + 'webp' => 'image/webp', + 'json' => 'application/json', + ]; + + public function __construct(private readonly Connection $connection) + { + if ($this->dataLoaded) { + return; + } + + $datasetPath = '/data/dataset.json'; + if (is_readable($datasetPath)) { + $this->dataset = json_decode(file_get_contents($datasetPath), true) ?? []; + } + $this->dataLoaded = true; + } + + #[Route('/baseline11', methods: ['GET', 'POST'])] + #[Route('/baseline2', methods: ['GET', 'POST'])] + public function baseline(Request $request): Response + { + $sum = array_sum($request->query->all()); + if ($request->isMethod('POST')) { + $sum += (int) $request->getContent(); + } + return new Response((string) $sum, 200, ['Content-Type' => 'text/plain']); + } + + #[Route('/pipeline')] + public function pipeline(): Response + { + return new Response('ok', 200, ['Content-Type' => 'text/plain']); + } + + #[Route('/json/{count}', requirements: ['count' => '\d+'])] + public function json(int $count, Request $request): Response + { + $count = max(0, min($count, count($this->dataset))); + $m = (int) ($request->query->get('m', 1) ?: 1); + $items = []; + for ($i = 0; $i < $count; $i++) { + $item = $this->dataset[$i]; + $item['total'] = $item['price'] * $item['quantity'] * $m; + $items[] = $item; + } + return new Response( + json_encode(['items' => $items, 'count' => $count], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES), + 200, + ['Content-Type' => 'application/json'] + ); + } + + #[Route('/upload', methods: ['POST'])] + public function upload(Request $request): Response + { + return new Response((string) strlen($request->getContent()), 200, ['Content-Type' => 'text/plain']); + } + + #[Route('/async-db')] + public function asyncDb(Request $request): Response + { + $min = (int) ($request->query->get('min', 10)); + $max = (int) ($request->query->get('max', 50)); + $limit = max(1, min(50, (int) ($request->query->get('limit', 50)))); + + try { + $stmt = $this->connection->prepare( + 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN ? AND ? LIMIT ?' + ); + $stmt->bindValue(1, $min); + $stmt->bindValue(2, $max); + $stmt->bindValue(3, $limit, ParameterType::INTEGER); + $result = $stmt->executeQuery(); + $rows = $result->fetchAllAssociative(); + + $items = array_map(static function (array $row): array { + $row['active'] = (bool) $row['active']; + $row['tags'] = json_decode($row['tags'], true); + $row['rating'] = [ + 'score' => (int) $row['rating_score'], + 'count' => (int) $row['rating_count'], + ]; + unset($row['rating_score'], $row['rating_count']); + return $row; + }, $rows); + + return new Response( + json_encode(['items' => $items, 'count' => count($items)]), + 200, + ['Content-Type' => 'application/json'] + ); + } catch (\Throwable) { + return new Response('{"items":[],"count":0}', 200, ['Content-Type' => 'application/json']); + } + } + + #[Route('/sqlite-db')] + public function sqliteDb(Request $request): Response + { + $min = (int) ($request->query->get('min', 10)); + $max = (int) ($request->query->get('max', 50)); + $limit = max(1, min(50, (int) ($request->query->get('limit', 50)))); + + $dbPath = $_ENV['SQLITE_DB_PATH'] ?? '/data/benchmark.db'; + + try { + $pdo = new \PDO('sqlite:' . $dbPath); + $pdo->setAttribute(\PDO::ATTR_ERRMODE, \PDO::ERRMODE_EXCEPTION); + + $stmt = $pdo->prepare( + 'SELECT id, name, category, price, quantity, active, tags, rating_score, rating_count FROM items WHERE price BETWEEN :min AND :max LIMIT :limit' + ); + $stmt->bindValue(':min', $min, \PDO::PARAM_INT); + $stmt->bindValue(':max', $max, \PDO::PARAM_INT); + $stmt->bindValue(':limit', $limit, \PDO::PARAM_INT); + $stmt->execute(); + + $rows = $stmt->fetchAll(\PDO::FETCH_ASSOC); + $items = array_map(static function (array $row): array { + $row['active'] = (bool) $row['active']; + $row['tags'] = json_decode($row['tags'], true); + $row['rating'] = [ + 'score' => (int) $row['rating_score'], + 'count' => (int) $row['rating_count'], + ]; + unset($row['rating_score'], $row['rating_count']); + return $row; + }, $rows); + + return new Response( + json_encode(['items' => $items, 'count' => count($items)]), + 200, + ['Content-Type' => 'application/json'] + ); + } catch (\Throwable) { + return new Response('{"items":[],"count":0}', 200, ['Content-Type' => 'application/json']); + } + } + + /** + * Fallback static file handler — only reached when TrueAsync StaticHandler + * could not find the file (on_missing: next) or when running in dev mode. + */ + #[Route('/static/{file}', requirements: ['file' => '.+'])] + public function static(string $file, Request $request): Response + { + $dir = '/data/static'; + $path = $dir . '/' . $file; + + if (!is_file($path) || !str_starts_with(realpath($path), realpath($dir))) { + return new Response('Not Found', 404, ['Content-Type' => 'text/plain']); + } + + $ext = pathinfo($file, PATHINFO_EXTENSION); + $mime = self::MIME_TYPES[$ext] ?? 'application/octet-stream'; + $data = file_get_contents($path); + + $headers = ['Content-Type' => $mime]; + $ae = $request->headers->get('Accept-Encoding', ''); + + $brPath = $path . '.br'; + $gzPath = $path . '.gz'; + + if (file_exists($brPath) && str_contains($ae, 'br')) { + $headers['Content-Encoding'] = 'br'; + return new Response(file_get_contents($brPath), 200, $headers); + } + + if (file_exists($gzPath) && str_contains($ae, 'gzip')) { + $headers['Content-Encoding'] = 'gzip'; + return new Response(file_get_contents($gzPath), 200, $headers); + } + + return new Response($data, 200, $headers); + } +} diff --git a/frameworks/symfony-spawn-tas/src/Kernel.php b/frameworks/symfony-spawn-tas/src/Kernel.php new file mode 100644 index 000000000..779cd1f2b --- /dev/null +++ b/frameworks/symfony-spawn-tas/src/Kernel.php @@ -0,0 +1,11 @@ +