From b89191c0189d586a1848d0208eea91ae402f8739 Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Tue, 21 Jul 2026 16:59:10 +0300 Subject: [PATCH] Update: 2026-07-21 16:59:10 --- docker/.env.example | 45 +++++++ docker/README.md | 79 ++++++++++++ docker/docker-compose.yml | 124 ++++++++++++++++++ docker/mysql/init/00-databases.sql | 7 + docker/nginx/default.conf | 40 ++++++ docker/php/Dockerfile.fpm | 9 ++ docker/php/Dockerfile.socket | 10 ++ docker/php/opcache.ini | 9 ++ docker/php/www-pool.conf | 12 ++ docs/30-siro-port-plan.md | 38 ++++++ stress_test/rate_test.js | 197 +++++++++++++++++++++++++++++ 11 files changed, 570 insertions(+) create mode 100644 docker/.env.example create mode 100644 docker/README.md create mode 100644 docker/docker-compose.yml create mode 100644 docker/mysql/init/00-databases.sql create mode 100644 docker/nginx/default.conf create mode 100644 docker/php/Dockerfile.fpm create mode 100644 docker/php/Dockerfile.socket create mode 100644 docker/php/opcache.ini create mode 100644 docker/php/www-pool.conf create mode 100644 docs/30-siro-port-plan.md create mode 100644 stress_test/rate_test.js diff --git a/docker/.env.example b/docker/.env.example new file mode 100644 index 00000000..edbb9ca3 --- /dev/null +++ b/docker/.env.example @@ -0,0 +1,45 @@ +# ============================================================ +# انسخه إلى .env واملأه من قيم اللايف — لا تخترع قيماً جديدة +# cp .env.example .env +# ============================================================ + +# --- عام --- +PHP_VERSION=8.2 +HTTP_PORT=8181 + +# فارغ أثناء التجربة = حارس REGION_MISMATCH في connect.php يسمح بأي host +# عند الربط بدومين حقيقي: ضع الدومين هنا +APP_DOMAIN= + +# --- قواعد البيانات (من داخل شبكة الحاويات: host = mysql) --- +MYSQL_ROOT_PASSWORD=change-me +DB_HOST=mysql +DB_PORT=3306 +DB_USER=root +DB_PASS=${MYSQL_ROOT_PASSWORD} +# أسماء القواعد — طابقها مع mysql/init/00-databases.sql ومع ما يتوقعه الكود +DB_NAME_PRIMARY=siro_primary +DB_NAME_RIDE=siro_ride +DB_NAME_TRACKING=siro_tracking +DB_NAME_WALLET=siro_wallet +DB_NAME_LOCATION=siro_location + +# --- Redis (نسخة واحدة تكفي النسخة المستنسخة — الفصل في اللايف كان فصل سيرفرات) --- +REDIS_HOST=redis +REDIS_PORT=6379 +REDIS_LOCATION_HOST=redis +REDIS_LOCATION_PORT=6379 + +# --- JWT (انسخ السر الحقيقي من .env اللايف — التطبيق لن يغيّر توكناته) --- +JWT_SECRET_KEY= +JWT_SECRET= + +# --- التخاطب الداخلي بين الباك إند والسوكيتات (أسماء الخدمات لا IPs) --- +LOCATION_SERVER_URL=http://socket_driver:2021 +RIDE_SOCKET_URL=http://socket_passenger:3031 +INTERNAL_KEY= + +# --- مزودات خارجية (OTP / دفع / خرائط) — انسخ من اللايف عند الحاجة للاختبار --- +# SMS_USERNAME= +# SMS_PASSWORD_EGYPT= +# ... diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 00000000..9a574f70 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,79 @@ +# سيرو في حاويات — دليل التشغيل والفحص + +> الهدف: نسخة سيرو كاملة على سيرفر واحد داخل compose واحدة، والتطبيق لا يتغير فيه +> شيء سوى الـ base URLs (تُبنى مرة واحدة بـ `.env` الجديد — envied وقت الترجمة). +> القرارات والأسباب في `Tripz/docs/30-siro-port-plan.md` §5. + +## 0) قبل أي شيء — على السيرفر +```bash +df -h / # ⚠️ الصندوق المشترك مزمنياً ~90% ممتلئ — الصور والقواعد تحتاج ~4-6GB +docker image prune -f # نظّف أولاً، وإن لم تكفِ المساحة فالفحص على صندوق آخر +``` + +## 1) الإعداد (مرة واحدة) +```bash +cd /path/to/Siro/docker +cp .env.example .env && nano .env # املأ الأسرار من قيم اللايف — خاصة JWT_SECRET وكلمات MySQL +# ⚠️ طابق أسماء القواعد في mysql/init/00-databases.sql مع ما يتوقعه الكود +docker compose build +# تبعيات composer للخدمات التي لا تحمل vendor/ داخلها: +docker compose run --rm socket_driver composer install +docker compose run --rm socket_passenger composer install +docker compose run --rm --workdir /var/www/backend php composer install +``` + +## 2) التشغيل والاستيراد +```bash +docker compose up -d +# استيراد السكيمات (أول مرة فقط): +docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_primary < ../backend/schema_primary.sql +docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_ride < ../backend/schema_ride.sql +docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_tracking < ../backend/schema_tracking.sql +docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_wallet < ../payment_server/WalletDB.sql +docker compose exec -T mysql mysql -uroot -p"$MYSQL_ROOT_PASSWORD" siro_location < ../loction_server/locationDB.sql +``` + +## 3) التحقق +```bash +docker compose ps # الست خدمات up +curl -s http://localhost:8080/backend/index.html # nginx→ملفات +curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/backend/connect.php # يرد (401/400 طبيعي بلا JWT) +curl -s http://localhost:8080/fpm-status # حالة حوض fpm +docker compose logs socket_driver | tail # Workerman أقلع على 2020/2021 +``` +اتصال التطبيق للتجربة: `http://IP:8080/backend` + سوكيت `http://IP:2020` — فقط بدّل +الـ base URLs في `.env` تبع التطبيق وأعد البناء. `APP_DOMAIN` فارغ = حارس +REGION_MISMATCH متعطل أثناء التجربة. + +## 4) اختبار الضغط (الهدف: 10-20 ألف رحلة/ساعة) +```bash +cd ../stress_test && npm install +# دخان أولاً (الأداة القديمة، دفعة صغيرة): +node load_test.js --trips=10 -b http://localhost:8080/backend -s http://localhost:2020 +# ثم المعدل المستمر: +node rate_test.js --rate 10000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020 +node rate_test.js --rate 20000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020 +node rate_test.js --rate 30000 --duration 5 ... # حد الانهيار — أين تبدأ p95 بالقفز؟ +``` +**أثناء التشغيل راقب من طرفية ثانية:** +```bash +docker stats # cpu/ذاكرة كل حاوية +docker compose exec mysql sh -c 'tail -f /var/lib/mysql/slow.log' # استعلامات >200ms +watch -n2 'curl -s http://localhost:8080/fpm-status' # طابور fpm (listen queue) +``` + +### قواعد قراءة النتيجة بصدق +1. **سيناريو الاختبار أخف من الرحلة الحقيقية بـ 3-5×** (4 نداءات API + 6 نبضات موقع؛ + الحقيقية فيها تسعير وبحث وقرب ودردشة). لتبنّي رقم "10k رحلة حقيقية بالذروة" + يجب أن يعبر الاختبار **30k** بنجاح >99% و p95<500ms. +2. الصندوق مشغول ~40% بغيرنا — **افحص خارج الذروة وبجولات قصيرة (5-10 د)**، + وأعد الجولة مرتين للتأكد من ثبات الرقم. +3. مولّد الحمل على نفس الصندوق يسرق ~5% CPU عند هذه المعدلات — مقبول؛ الأدق + تشغيله من جهاز آخر نحو `http://IP:8080`. +4. المُلتزم به للعملاء = الرقم الذي عبر الاختبار، لا أكثر. + +## 5) ملاحظات أداء محسومة (لماذا الحاويات لا تبطئنا) +- الحاوية على لينكس = عمليات عادية بنفس النواة (namespaces/cgroups) — **لا محاكاة ولا آلة افتراضية**. حمل CPU/ذاكرة ≈ صفر. +- المكان الوحيد الذي يُخسر فيه أداء فعلاً: كتابة قاعدة البيانات فوق overlayfs — **محلول** بـ volume مسمّى (`mysql-data`). +- أداء PHP الحقيقي يصنعه opcache + مقاس حوض fpm + فهارس MySQL — كلها مضبوطة في `php/` وليست متعلقة بالدوكر أصلاً. +- التقسيم إلى 6 حاويات لا يكلف أداءً (نفس العمليات) ويعطي: إعادة تشغيل خدمة وحدها، حدود ذاكرة لكل خدمة، لوغات منفصلة، واستنساخ عميل = `clone + .env + up`. diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml new file mode 100644 index 00000000..0d34502a --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,124 @@ +# ============================================================ +# سيرو في حاويات — نسخة واحدة كاملة على سيرفر واحد +# ============================================================ +# المبدأ: التقسيم بالدور (nginx / fpm / سوكيتات / mysql / redis) +# لا حاوية-واحدة-فيها-كل-شيء، ولا تفتيت microservices. +# الأداء = نفس العمليات على نفس النواة؛ الفرق تشغيلي فقط. +# +# التشغيل: docker compose up -d --build (انظر README.md) +# ============================================================ + +name: siro + +services: + nginx: + image: nginx:1.27-alpine + ports: + - "${HTTP_PORT:-8080}:80" + volumes: + - ./nginx/default.conf:/etc/nginx/conf.d/default.conf:ro + - ../backend:/var/www/backend:ro + - ../payment_server/v2:/var/www/v2:ro + depends_on: + - php + mem_limit: 256m + restart: unless-stopped + + # php-fpm واحدة تخدم كل الخدمات الطلبية (backend + payment v2) + # التقسيم لأبعد من هذا لا يضيف أداءً — نفس الصورة، نفس النواة. + php: + build: + context: ./php + dockerfile: Dockerfile.fpm + args: + PHP_VERSION: "${PHP_VERSION:-8.2}" + volumes: + - ../backend:/var/www/backend + - ../payment_server/v2:/var/www/v2 + - ./php/opcache.ini:/usr/local/etc/php/conf.d/zz-opcache.ini:ro + - ./php/www-pool.conf:/usr/local/etc/php-fpm.d/zz-pool.conf:ro + env_file: .env + depends_on: + - mysql + - redis + mem_limit: 2g + restart: unless-stopped + + # سوكيت السائقين (Workerman + PHPSocketIO) — WS بورت 2020 + HTTP داخلي 2021 + # عملية دائمة، لا علاقة لها بـ fpm — حاوية مستقلة إجبارياً. + socket_driver: + build: + context: ./php + dockerfile: Dockerfile.socket + args: + PHP_VERSION: "${PHP_VERSION:-8.2}" + command: ["php", "driver_socket.php", "start"] + working_dir: /app + volumes: + - ../loction_server:/app + env_file: .env + ports: + - "2020:2020" # WS للسائقين — عام + # 2021 داخلي فقط: الباك إند يناديه عبر http://socket_driver:2021 + depends_on: + - redis + mem_limit: 768m + restart: unless-stopped + + # سوكيت الركاب (ride_server) — WS بورت 3030 + HTTP داخلي 3031 + socket_passenger: + build: + context: ./php + dockerfile: Dockerfile.socket + args: + PHP_VERSION: "${PHP_VERSION:-8.2}" + command: ["php", "passenger_socket.php", "start"] + working_dir: /app + volumes: + - ../ride_server:/app + env_file: .env + ports: + - "3030:3030" + depends_on: + - redis + mem_limit: 512m + restart: unless-stopped + + mysql: + image: mysql:8.4 + environment: + MYSQL_ROOT_PASSWORD: "${MYSQL_ROOT_PASSWORD:?set in .env}" + # بيانات القاعدة على volume مسمّى — يتجاوز overlayfs = أداء قرص شبه أصلي. + volumes: + - mysql-data:/var/lib/mysql + - ./mysql/init:/docker-entrypoint-initdb.d:ro + command: + - --innodb-buffer-pool-size=1G + - --max-connections=300 + - --innodb-flush-log-at-trx-commit=1 + - --slow-query-log=1 + - --long-query-time=0.2 + - --slow-query-log-file=/var/lib/mysql/slow.log + ports: + - "127.0.0.1:33060:3306" # للإدارة من السيرفر نفسه فقط — ليس للعامة + mem_limit: 2560m + restart: unless-stopped + + redis: + image: redis:7-alpine + command: + - redis-server + - --maxmemory + - 400mb + - --maxmemory-policy + - noeviction + - --appendonly + - "yes" + volumes: + - redis-data:/data + mem_limit: 512m + restart: unless-stopped + +volumes: + mysql-data: + redis-data: diff --git a/docker/mysql/init/00-databases.sql b/docker/mysql/init/00-databases.sql new file mode 100644 index 00000000..c0aae319 --- /dev/null +++ b/docker/mysql/init/00-databases.sql @@ -0,0 +1,7 @@ +-- تُنشأ القواعد فارغة عند أول إقلاع فقط (volume جديد). +-- ⚠️ عدّل الأسماء لتطابق ما تتوقعه ملفات .env في اللايف قبل أول تشغيل. +CREATE DATABASE IF NOT EXISTS siro_primary CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS siro_ride CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS siro_tracking CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS siro_wallet CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; +CREATE DATABASE IF NOT EXISTS siro_location CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; diff --git a/docker/nginx/default.conf b/docker/nginx/default.conf new file mode 100644 index 00000000..2532e93d --- /dev/null +++ b/docker/nginx/default.conf @@ -0,0 +1,40 @@ +# نقطة واحدة لكل الخدمات الطلبية — التطبيق يغيّر الدومين فقط: +# /backend/... → الباك إند الرئيسي (نفس شكل مسارات jordan-siro) +# /v2/main/... → خادم المدفوعات +# السوكيتات تُنشر مباشرة من حاوياتها (2020 / 3030) — لا تمر من هنا. + +server { + listen 80; + server_name _; + root /var/www; + index index.php index.html; + client_max_body_size 25m; + + access_log off; # أثناء اختبار الضغط؛ فعّله عند الحاجة + error_log /dev/stderr warn; + + location ~ \.php$ { + try_files $uri =404; + include fastcgi_params; + fastcgi_pass php:9000; + fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; + fastcgi_read_timeout 60s; + fastcgi_buffers 16 16k; + fastcgi_buffer_size 32k; + } + + # حالة حوض fpm — من داخل شبكة الحاويات أو من السيرفر نفسه فقط + location = /fpm-status { + allow 172.16.0.0/12; + allow 127.0.0.1; + deny all; + include fastcgi_params; + fastcgi_pass php:9000; + fastcgi_param SCRIPT_NAME /status; + fastcgi_param SCRIPT_FILENAME /status; + } + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/docker/php/Dockerfile.fpm b/docker/php/Dockerfile.fpm new file mode 100644 index 00000000..46c4e5ce --- /dev/null +++ b/docker/php/Dockerfile.fpm @@ -0,0 +1,9 @@ +# صورة الخدمات الطلبية (backend + payment v2) +ARG PHP_VERSION=8.2 +FROM php:${PHP_VERSION}-fpm + +ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ +RUN chmod +x /usr/local/bin/install-php-extensions && \ + install-php-extensions mysqli pdo_mysql redis opcache gd zip @composer + +WORKDIR /var/www diff --git a/docker/php/Dockerfile.socket b/docker/php/Dockerfile.socket new file mode 100644 index 00000000..ded2f425 --- /dev/null +++ b/docker/php/Dockerfile.socket @@ -0,0 +1,10 @@ +# صورة السوكيتات الدائمة (Workerman) — driver_socket و passenger_socket +# ext-event ضرورية فوق ~1024 اتصال متزامن (Workerman يلتقطها تلقائياً إن وُجدت) +ARG PHP_VERSION=8.2 +FROM php:${PHP_VERSION}-cli + +ADD https://github.com/mlocati/docker-php-extension-installer/releases/latest/download/install-php-extensions /usr/local/bin/ +RUN chmod +x /usr/local/bin/install-php-extensions && \ + install-php-extensions pcntl posix sockets event redis @composer + +WORKDIR /app diff --git a/docker/php/opcache.ini b/docker/php/opcache.ini new file mode 100644 index 00000000..aeb09dfe --- /dev/null +++ b/docker/php/opcache.ini @@ -0,0 +1,9 @@ +; opcache هو الفرق الحقيقي في أداء PHP — ليس الدوكر +opcache.enable=1 +opcache.memory_consumption=192 +opcache.interned_strings_buffer=16 +opcache.max_accelerated_files=20000 +; الكود bind-mounted في التجربة → إعادة تحقق كل 60 ثانية +; عند التحويل لمنتج (كود مخبوز في الصورة): اجعلها 0 مع validate_timestamps=0 +opcache.validate_timestamps=1 +opcache.revalidate_freq=60 diff --git a/docker/php/www-pool.conf b/docker/php/www-pool.conf new file mode 100644 index 00000000..493a0a6d --- /dev/null +++ b/docker/php/www-pool.conf @@ -0,0 +1,12 @@ +; حوض fpm مضبوط لصندوق مشترك 6 أنوية / 12GB (حصة الحاوية 2GB) +; قانون Little: ‏170 rps × ‏0.05 ثانية ≈ 9 طلبات متزامنة فقط — 48 عاملاً فيها هامش واسع +[www] +pm = dynamic +pm.max_children = 48 +pm.start_servers = 8 +pm.min_spare_servers = 4 +pm.max_spare_servers = 16 +pm.max_requests = 1000 +pm.status_path = /status +slowlog = /proc/self/fd/2 +request_slowlog_timeout = 3s diff --git a/docs/30-siro-port-plan.md b/docs/30-siro-port-plan.md new file mode 100644 index 00000000..7b82c8cd --- /dev/null +++ b/docs/30-siro-port-plan.md @@ -0,0 +1,38 @@ +# Siro Backend Migration Plan & Docker Architecture (Docs/30) + +## 1. Overview +This document outlines the architectural decisions made for migrating the Siro backend to a containerized Docker environment. It addresses questions regarding performance overhead, container granularity, and technology stack choices (PHP vs Node.js). + +## 2. Docker Overhead vs Bare Metal +- **Finding:** Docker on Linux is not a virtual machine. It utilizes Linux namespaces and cgroups to isolate processes. +- **CPU & Memory:** The CPU and Memory overhead of running PHP inside Docker is effectively **zero** compared to bare metal. The processes run natively on the host kernel. +- **Disk I/O:** The only measurable overhead comes from writing to the container's overlayfs (file system). +- **Solution:** We mitigate this by using **named volumes** for MySQL data (`mysql-data`), which bypasses overlayfs and provides near-native disk performance. +- **Conclusion:** Docker is heavily recommended. It gives you instant reproducibility (clone -> `.env` -> up) with a negligible performance difference (0-3%). The real performance factors are PHP opcache, MySQL indexes, and fpm pool sizes, all of which are properly tuned in the container configurations. + +## 3. Container Strategy: Single vs Multiple Containers +- **Finding:** Grouping everything (Nginx, PHP-FPM, MySQL, Redis, Node/Workerman) into a single container is considered a Docker anti-pattern. +- **Decision:** The architecture uses **6 specialized containers** running within a single server using Docker Compose: + 1. `nginx`: Handles incoming HTTP traffic and reverse proxies. + 2. `php`: A single PHP-FPM container that handles all HTTP API requests (backend + payment). + 3. `socket_driver`: PHP CLI container running Workerman for driver WebSockets (Port 2020). + 4. `socket_passenger`: PHP CLI container running Workerman for passenger WebSockets (Port 3030). + 5. `mysql`: Database container. + 6. `redis`: Caching container. +- **Why?** Performance is identical to a monolithic single-container setup because all processes still run on the same server/kernel. However, this split gives critical operational advantages: independent service restarts, individual memory limits (e.g., preventing MySQL from consuming all RAM and crashing PHP), and separated logs. Over-segmenting beyond this (microservices) would be overkill. + +## 4. WebSockets: PHP (Workerman) vs Node.js +- **Finding:** Node.js uses an event-loop and is natively non-blocking, making it extremely lightweight for massive WebSocket concurrency. PHP-FPM allocates a process per request, which is bad for WebSockets. +- **However,** the current implementation uses **Workerman** for PHP WebSockets. Workerman implements the exact same event-loop model as Node.js within PHP. +- **Scale:** At a target of 10,000 to 20,000 trips per hour, the system experiences roughly 3 to 6 trips per second. This load is well within the capabilities of both Workerman and Node.js. +- **Conclusion:** There is no need to rewrite the WebSockets in Node.js. Workerman handles the event-driven load efficiently. The real bottleneck will be the database and complex business logic, not the socket engine. + +## 5. Environment Variables (.env) +- The architecture mounts the `backend` directory directly into the `php` container. +- The `.env` file can safely reside in the root `backend` or project directory on the server as it currently does. +- By passing `env_file: .env` to the containers in `docker-compose.yml`, Docker injects the environment variables natively, making them available via PHP's `getenv()`. This requires zero code changes to the PHP application. + +## 6. Stress Testing +- A continuous stress test script (`rate_test.js`) is used instead of a batch load tester. +- It generates JWTs natively in Node to avoid blocking on PHP processes. +- The success criteria for 10,000 trips/hour is less than 1% failure rate and a p95 response time under 500ms. diff --git a/stress_test/rate_test.js b/stress_test/rate_test.js new file mode 100644 index 00000000..32fb8c4a --- /dev/null +++ b/stress_test/rate_test.js @@ -0,0 +1,197 @@ +/** + * rate_test.js — اختبار ضغط بمعدل مستمر (رحلات/ساعة) مع percentiles + * ================================================================== + * يكمل load_test.js (الذي يبقى لفحص الدخان بدفعة واحدة). الفروق: + * 1. معدل وصول مستمر: --rate رحلة/ساعة طوال --duration دقيقة (مع ramp) + * 2. توليد JWT داخل Node (HS256 عبر crypto) — لا exec لعملية PHP لكل رحلة + * (كانت execSync تخنق مولّد الحمل نفسه فوق ~2 رحلة/ثانية) + * 3. قياس p50/p95/p99 لكل نقطة نداء + زمن العرض + الرحلة كاملة + * + * أمثلة: + * node rate_test.js --rate 10000 --duration 10 -b http://localhost:8080/backend -s http://localhost:2020 + * node rate_test.js --rate 20000 --duration 5 --env ../backend/.env + */ + +const io = require('socket.io-client'); +const axios = require('axios'); +const crypto = require('crypto'); +const fs = require('fs'); +const path = require('path'); +const yargs = require('yargs/yargs'); +const { hideBin } = require('yargs/helpers'); + +const argv = yargs(hideBin(process.argv)) + .option('rate', { alias: 'r', type: 'number', default: 10000, description: 'رحلات في الساعة' }) + .option('duration', { alias: 'd', type: 'number', default: 10, description: 'مدة الاختبار بالدقائق' }) + .option('ramp', { type: 'number', default: 1, description: 'دقائق التصعيد التدريجي' }) + .option('base-url', { alias: 'b', type: 'string', default: 'http://localhost:8080/backend' }) + .option('socket-url', { alias: 's', type: 'string', default: 'http://localhost:2020' }) + .option('env', { type: 'string', default: path.join(__dirname, '..', 'backend', '.env'), description: 'ملف .env لقراءة سر JWT' }) + .option('jwt-secret', { type: 'string', description: 'تجاوز مباشر لسر JWT' }) + .argv; + +// ── سر JWT: نفس أولويات generate_mock_data.php ── +function loadSecret() { + if (argv.jwtSecret) return argv.jwtSecret; + const env = {}; + try { + for (const line of fs.readFileSync(argv.env, 'utf8').split('\n')) { + const t = line.trim(); + if (!t || t.startsWith('#') || !t.includes('=')) continue; + const i = t.indexOf('='); + env[t.slice(0, i).trim()] = t.slice(i + 1).trim().replace(/^["']|["']$/g, ''); + } + } catch (e) { /* يسقط للفحص أدناه */ } + if (env.JWT_SECRET_KEY_PATH && fs.existsSync(env.JWT_SECRET_KEY_PATH)) + return fs.readFileSync(env.JWT_SECRET_KEY_PATH, 'utf8').trim(); + return env.JWT_SECRET_KEY || env.JWT_SECRET || ''; +} + +const b64u = (buf) => Buffer.from(buf).toString('base64url'); +function makeJwt(secret, driverId) { + const header = b64u(JSON.stringify({ alg: 'HS256', typ: 'JWT' })); + const body = b64u(JSON.stringify({ sub: String(driverId), role: 'driver', iat: Math.floor(Date.now() / 1000), exp: Math.floor(Date.now() / 1000) + 86400 })); + const sig = crypto.createHmac('sha256', secret).update(`${header}.${body}`).digest('base64url'); + return `${header}.${body}.${sig}`; +} + +// ── مقاييس ── +const lat = { socket_connect: [], add_ride: [], offer_delay: [], accept: [], start: [], finish: [], trip_e2e: [] }; +const counters = { launched: 0, ok: 0, fail: 0, socketErr: 0, apiErr: 0, timeout: 0 }; +let active = 0; + +function pct(arr, p) { + if (!arr.length) return 0; + const s = [...arr].sort((a, b) => a - b); + return s[Math.min(s.length - 1, Math.floor((p / 100) * s.length))]; +} +const fmt = (a) => `p50=${pct(a, 50)}ms p95=${pct(a, 95)}ms p99=${pct(a, 99)}ms max=${Math.max(0, ...a)}ms (n=${a.length})`; + +async function post(name, url, form) { + const t0 = Date.now(); + try { + const res = await axios.post(url, new URLSearchParams(form).toString(), { + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, timeout: 15000, + }); + lat[name].push(Date.now() - t0); + return res; + } catch (e) { + counters.apiErr++; + throw e; + } +} + +function simulateTrip(i, secret) { + const driverId = 900000 + i, passengerId = 800000 + i; + const jwt = makeJwt(secret, driverId); + const t0 = Date.now(); + active++; + counters.launched++; + + return new Promise((resolve) => { + let done = false, addRideAt = 0; + const socket = io(argv.socketUrl, { + query: { driver_id: String(driverId), platform: 'android', jwt }, + reconnection: false, timeout: 10000, forceNew: true, + }); + const finish = (ok) => { + if (done) return; + done = true; + ok ? counters.ok++ : counters.fail++; + if (ok) lat.trip_e2e.push(Date.now() - t0); + active--; + try { socket.disconnect(); } catch (_) {} + resolve(ok); + }; + + socket.on('connect_error', () => { counters.socketErr++; finish(false); }); + socket.on('error', () => { counters.socketErr++; finish(false); }); + + socket.on('connect', async () => { + lat.socket_connect.push(Date.now() - t0); + try { + addRideAt = Date.now(); + await post('add_ride', `${argv.baseUrl}/ride/rides/add_ride.php`, { + passenger_id: String(passengerId), + start_location: '31.95,35.91', end_location: '31.96,35.92', + price: '5', price_token: 'dummy', distance: '2', carType: 'Economy', + passenger_name: 'Test Passenger', passenger_phone: '0790000000', + start_name: 'Start', end_name: 'End', + }); + } catch (_) { finish(false); } + }); + + socket.on('market_new_ride', async (payload) => { + const rideId = payload.id || payload[0]?.id; + if (!rideId) return finish(false); + lat.offer_delay.push(Date.now() - addRideAt); + try { + await post('accept', `${argv.baseUrl}/ride/rides/acceptRide.php`, { id: String(rideId), driver_id: String(driverId), status: 'accepted' }); + let steps = 0; + const iv = setInterval(async () => { + steps++; + socket.emit('update_location', { lat: 31.95 + steps * 0.001, lng: 35.91 + steps * 0.001, heading: 90, speed: 40, status: 'on' }); + try { + if (steps === 3) await post('start', `${argv.baseUrl}/ride/rides/start_ride.php`, { id: String(rideId), driver_id: String(driverId), status: 'start' }); + if (steps === 6) { + clearInterval(iv); + await post('finish', `${argv.baseUrl}/ride/rides/finish_ride_updates.php`, { id: String(rideId), driver_id: String(driverId), status: 'finish' }); + finish(true); + } + } catch (_) { clearInterval(iv); finish(false); } + }, 1000); + } catch (_) { finish(false); } + }); + + setTimeout(() => { if (!done) { counters.timeout++; finish(false); } }, 45000); + }); +} + +async function main() { + const secret = loadSecret(); + if (!secret) { + console.error(`❌ لا يوجد سر JWT — مرّر --jwt-secret أو --env لملف فيه JWT_SECRET_KEY (جرّبت: ${argv.env})`); + process.exit(1); + } + + const perSec = argv.rate / 3600; + const totalMs = argv.duration * 60 * 1000; + const rampMs = Math.min(argv.ramp * 60 * 1000, totalMs); + console.log(`\n🚀 اختبار معدل مستمر: ${argv.rate} رحلة/ساعة (${perSec.toFixed(2)}/ث) لمدة ${argv.duration} دقيقة (تصعيد ${argv.ramp} د)`); + console.log(`📡 API: ${argv.baseUrl}\n🔌 Socket: ${argv.socketUrl}\n`); + + const t0 = Date.now(); + let i = 0; + const inflight = []; + + const progress = setInterval(() => { + const el = ((Date.now() - t0) / 1000).toFixed(0); + console.log(`[${el}s] أُطلق=${counters.launched} نجح=${counters.ok} فشل=${counters.fail} جارٍ=${active} | add_ride p95=${pct(lat.add_ride, 95)}ms عرض p95=${pct(lat.offer_delay, 95)}ms`); + }, 10000); + + // مجدول مصحح الانحراف: يحسب كم رحلة كان يجب أن تكون قد انطلقت ويطلق الفرق + while (Date.now() - t0 < totalMs) { + const el = Date.now() - t0; + const rampFactor = el < rampMs ? el / rampMs : 1; + const due = Math.floor((el / 1000) * perSec * rampFactor); + while (i < due) { i++; inflight.push(simulateTrip(i, secret)); } + await new Promise((r) => setTimeout(r, 100)); + } + + console.log(`\n⏳ توقف الإطلاق (${counters.launched} رحلة) — انتظار الرحلات الجارية (${active})…`); + await Promise.allSettled(inflight); + clearInterval(progress); + + const durMin = ((Date.now() - t0) / 60000).toFixed(1); + const achieved = Math.round((counters.ok / ((Date.now() - t0) / 3600000))); + console.log(`\n${'='.repeat(60)}\n📊 النتائج (${durMin} دقيقة)\n${'='.repeat(60)}`); + console.log(`أُطلق: ${counters.launched} | نجح: ${counters.ok} | فشل: ${counters.fail} (سوكيت=${counters.socketErr} api=${counters.apiErr} مهلة=${counters.timeout})`); + console.log(`المعدل المحقق فعلياً: ~${achieved} رحلة/ساعة\n`); + for (const [k, v] of Object.entries(lat)) console.log(` ${k.padEnd(15)} ${fmt(v)}`); + console.log(`${'='.repeat(60)}`); + const pass = counters.fail / Math.max(1, counters.launched) < 0.01 && pct(lat.add_ride, 95) < 500; + console.log(pass ? '✅ الحكم: تحت العتبة (فشل <1% و p95<500ms)' : '⚠️ الحكم: فوق العتبة — انظر أبطأ نقطة أعلاه وسجل slow.log في MySQL'); + process.exit(0); +} + +main();