diff --git a/.env.example b/.env.example index 578e381e8..641ffa2da 100644 --- a/.env.example +++ b/.env.example @@ -26,8 +26,9 @@ CACHE_DRIVER=file SESSION_DRIVER=database SESSION_LIFETIME=720 QUEUE_DRIVER=sync +QUEUE_CONNECTION=sync -REDIS_HOST=127.0.0.1 +REDIS_HOST=ogamex-redis REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/.env.example-prod b/.env.example-prod index 7b1b7ed50..9bdae86bb 100644 --- a/.env.example-prod +++ b/.env.example-prod @@ -25,9 +25,11 @@ BROADCAST_DRIVER=log CACHE_DRIVER=file SESSION_DRIVER=database SESSION_LIFETIME=720 -QUEUE_DRIVER=sync +QUEUE_DRIVER=redis +QUEUE_CONNECTION=redis -REDIS_HOST=127.0.0.1 + +REDIS_HOST=ogamex-redis REDIS_PASSWORD=null REDIS_PORT=6379 diff --git a/.github/workflows/run-tests-sqlite.yml b/.github/workflows/run-tests-sqlite.yml index 700c1d2f0..06c204017 100644 --- a/.github/workflows/run-tests-sqlite.yml +++ b/.github/workflows/run-tests-sqlite.yml @@ -35,6 +35,8 @@ jobs: # Laravel setup steps - name: Copy .env run: php -r "file_exists('.env') || copy('.env.example', '.env');" + - name: Set APP_ENV to testing │ + run: sed -i 's/APP_ENV=.*/APP_ENV=testing/' .env - name: Composer update run: composer update --lock - name: Install Dependencies diff --git a/Dockerfile b/Dockerfile index f2702d974..0a031d0fa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -30,7 +30,9 @@ RUN apt-get update && apt-get install -y \ # Install extensions RUN docker-php-ext-install ffi pdo_mysql mbstring zip exif pcntl && \ docker-php-ext-configure gd --with-freetype=/usr/include/ --with-jpeg=/usr/include/ && \ - docker-php-ext-install gd + docker-php-ext-install gd && \ + pecl install redis && \ + docker-php-ext-enable redis # Enable and configure opcache only if OPCACHE_ENABLE is set to "1" ARG OPCACHE_ENABLE=0 diff --git a/app/Console/Commands/TestFleetQueue.php b/app/Console/Commands/TestFleetQueue.php new file mode 100644 index 000000000..243d890ef --- /dev/null +++ b/app/Console/Commands/TestFleetQueue.php @@ -0,0 +1,111 @@ +argument('userId'); + $galaxy = (int) $this->argument('galaxy'); + $system = (int) $this->argument('system'); + $position = (int) $this->argument('position'); + $delay = (int) $this->option('delay'); + + // Get planet + $planet_model = Planet::where('user_id', $user_id)->first(); + if (!$planet_model) { + return 1; + } + + // Setup services + $planet_factory = app(PlanetServiceFactory::class); + $source_planet = $planet_factory->make($planet_model->id); + $player_factory = app(PlayerServiceFactory::class); + $player = $player_factory->make($user_id); + + // Setup test data + $target_coordinate = new Coordinate($galaxy, $system, $position); + $player->setResearchLevel('computer_technology', 10); + $source_planet->addUnit('light_fighter', 6000000); + $source_planet->addResources(new Resources(1000000000, 1000000000, 1000000000, 0)); + + $attack_mission = app(AttackMission::class); + $missions = []; + + // Mission 1: 1M light fighters + $units1 = new UnitCollection(); + $units1->addUnit(ObjectService::getUnitObjectByMachineName('light_fighter'), 1000000); + try { + $mission1 = $attack_mission->start( + $source_planet, + $target_coordinate, + PlanetType::Planet, + $units1, + new Resources(0, 0, 0, 0), + $delay + ); + $missions[] = $mission1->id; + } catch (\Exception $e) { + } + + // Mission 2: 2M light fighters + $units2 = new UnitCollection(); + $units2->addUnit(ObjectService::getUnitObjectByMachineName('light_fighter'), 2000000); + try { + $mission2 = $attack_mission->start( + $source_planet, + $target_coordinate, + PlanetType::Planet, + $units2, + new Resources(0, 0, 0, 0), + $delay + ); + $missions[] = $mission2->id; + } catch (\Exception $e) { + } + + // Mission 3: 3M light fighters + $units3 = new UnitCollection(); + $units3->addUnit(ObjectService::getUnitObjectByMachineName('light_fighter'), 3000000); + try { + $mission3 = $attack_mission->start( + $source_planet, + $target_coordinate, + PlanetType::Planet, + $units3, + new Resources(0, 0, 0, 0), + $delay + ); + $missions[] = $mission3->id; + } catch (\Exception $e) { + } + + if (empty($missions)) { + return 1; + } + + return 0; + } +} diff --git a/app/Console/Commands/Tests/TestRaceConditionGameMission.php b/app/Console/Commands/Tests/TestRaceConditionGameMission.php index ce23c72e5..914d3a6c9 100644 --- a/app/Console/Commands/Tests/TestRaceConditionGameMission.php +++ b/app/Console/Commands/Tests/TestRaceConditionGameMission.php @@ -43,7 +43,6 @@ public function handle(): int // Set up the test environment. parent::setup(); - // Set up parameters for this specific test. $this->testSetup(); @@ -57,6 +56,8 @@ public function handle(): int 'time_arrival' => time() - 1]); // Run the parallel requests against the overview page which should start the return mission. + $this->playerService->updateFleetMissions(); + $this->runParallelRequests('overview'); // Assert the database state after the test. diff --git a/app/GameMissions/Abstracts/GameMission.php b/app/GameMissions/Abstracts/GameMission.php index c6290dbe1..e8c528a63 100644 --- a/app/GameMissions/Abstracts/GameMission.php +++ b/app/GameMissions/Abstracts/GameMission.php @@ -11,6 +11,7 @@ use OGame\GameMissions\Models\MissionPossibleStatus; use OGame\GameObjects\Models\Units\UnitCollection; use OGame\GameMissions\ExpeditionMission; +use OGame\Jobs\ProcessFleetMissions; use OGame\Models\Enums\PlanetType; use OGame\Models\FleetMission; use OGame\Models\Planet\Coordinate; @@ -274,6 +275,8 @@ public function start(PlanetService $planet, Coordinate $targetCoordinate, Plane if ($mission->time_arrival < Carbon::now()->timestamp) { $this->process($mission); } + $arrival_time_to_carbon = Carbon::parse($mission->time_arrival); + ProcessFleetMissions::dispatch($mission, $planet->getPlayer())->delay($arrival_time_to_carbon); return $mission; } @@ -366,6 +369,11 @@ protected function startReturn(FleetMission $parentMission, Resources $resources // If the mission is in the past, process it immediately. if ($mission->time_arrival < Carbon::now()->timestamp) { $this->process($mission); + } else { + // Dispatch job for future return mission arrival + $player = $this->playerServiceFactory->make($mission->user_id); + $arrival_time_to_carbon = Carbon::parse($mission->time_arrival); + ProcessFleetMissions::dispatch($mission, $player)->delay($arrival_time_to_carbon); } } diff --git a/app/Http/Middleware/GlobalGame.php b/app/Http/Middleware/GlobalGame.php index c970e48c0..43d58a993 100644 --- a/app/Http/Middleware/GlobalGame.php +++ b/app/Http/Middleware/GlobalGame.php @@ -45,7 +45,10 @@ public function handle(Request $request, Closure $next): mixed $player->planets->current()->update(); // Update all fleet missions of player that are associated with any of the player's planets. - $player->updateFleetMissions(); + + if (app()->environment('testing')) { + $player->updateFleetMissions(); + } } return $next($request); diff --git a/app/Jobs/ProcessFleetMissions.php b/app/Jobs/ProcessFleetMissions.php new file mode 100755 index 000000000..ba8af94ad --- /dev/null +++ b/app/Jobs/ProcessFleetMissions.php @@ -0,0 +1,224 @@ +mission = $mission; + $this->player = $player; + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + // If no destination planet (e.g., expeditions to empty space), + // use mission ID as lock key to ensure at least job-level uniqueness + $lock_key = $this->mission->planet_id_to + ? 'planet_'.$this->mission->planet_id_to + : 'mission_' . $this->mission->id; + + return [ + (new WithoutOverlapping($lock_key)) + ->releaseAfter(1) // Retry after 1 second if lock is held + ]; + } + + /** + * Execute the job. + */ + public function handle(): void + { + \Log::info("FLEET MISSION STARTED FOR PLANET {$this->mission->planet_id_to}"); + if ($this->mission->canceled == 1) { + return; + } + // FIFO check: Is there an earlier-arriving unprocessed mission targeting the same planet? + if ($this->mission->planet_id_to) { + try { + // Small delay to ensure database consistency before checking + usleep(50000); // 50ms delay + + $older_mission_exists = FleetMission::where('planet_id_to', $this->mission->planet_id_to) + ->where('processed', 0) + ->where(function ($query) { + // Earlier arrival time, OR same arrival time but lower ID (tie-breaker) + $query->where('time_arrival', '<', $this->mission->time_arrival) + ->orWhere(function ($q) { + $q->where('time_arrival', '=', $this->mission->time_arrival) + ->where('id', '<', $this->mission->id); + }); + }) + ->lockForUpdate() + ->exists(); + + \Log::info('FLEET OLDER MISSION EXISTS', ['exists' => $older_mission_exists]); + + if ($older_mission_exists) { + // There's an earlier-arriving mission waiting, release this job back to queue + \Log::info('FLEET MISSION WAITING', [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'time_arrival' => $this->mission->time_arrival, + 'reason' => 'Earlier-arriving mission exists, maintaining FIFO order by arrival time', + ]); + $this->release(1); // Retry after 1 second + return; + } else { + \Log::info('FLEET NO MISSION WAITING', [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'older_mission_exists' => $older_mission_exists, + ]); + } + } catch (Throwable $e) { + \Log::error('FLEET MISSION FIFO CHECK FAILED', [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'error' => $e->getMessage(), + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + ]); + // On database error, retry after 5 seconds + $this->release(5); + return; + } + } + + // Calculate processing delay + $expected_arrival = $this->mission->time_arrival; + $actual_processing_time = time(); + $processing_delay = $actual_processing_time - $expected_arrival; + + $context = [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'mission_type' => $this->mission->mission_type, + 'lock_key' => $this->mission->planet_id_to + ? 'planet_'.$this->mission->planet_id_to + : 'mission_' . $this->mission->id, + 'worker' => getmypid(), + 'expected_arrival' => $expected_arrival, + 'actual_processing_time' => $actual_processing_time, + 'processing_delay_seconds' => $processing_delay, + ]; + + \Log::info('FLEET MISSION START', $context); + + // Warning if processing is delayed more than 5 seconds + if ($processing_delay > 5) { + \Log::warning('FLEET MISSION PROCESSING DELAYED', [ + 'mission_id' => $this->mission->id, + 'delay_seconds' => $processing_delay, + 'reason' => 'Mission processed later than expected arrival time', + ]); + } + + $start_time = microtime(true); + + try { + $fleet_mission_service = resolve(FleetMissionService::class, ['player' => $this->player]); + $fleet_mission_service->updateMission($this->mission); + + $duration = round((microtime(true) - $start_time) * 1000, 2); + + \Log::info('FLEET MISSION COMPLETE', [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'duration_ms' => $duration, + 'worker' => getmypid() + ]); + } catch (Throwable $e) { + $duration = round((microtime(true) - $start_time) * 1000, 2); + + \Log::error('FLEET MISSION PROCESSING FAILED', [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'mission_type' => $this->mission->mission_type, + 'duration_ms' => $duration, + 'error' => $e->getMessage(), + 'exception' => get_class($e), + 'file' => $e->getFile(), + 'line' => $e->getLine(), + 'trace' => $e->getTraceAsString(), + 'worker' => getmypid() + ]); + + throw $e; + } + } + + /** + * Handle a job failure. + * + * @param Throwable $exception + * @return void + */ + public function failed(Throwable $exception): void + { + Log::critical('FLEET MISSION JOB PERMANENTLY FAILED', [ + 'mission_id' => $this->mission->id, + 'planet_id_to' => $this->mission->planet_id_to, + 'mission_type' => $this->mission->mission_type, + 'user_id' => $this->mission->user_id, + 'error' => $exception->getMessage(), + 'exception' => get_class($exception), + 'file' => $exception->getFile(), + 'line' => $exception->getLine(), + 'trace' => $exception->getTraceAsString(), + 'worker' => getmypid(), + 'timestamp' => time(), + ]); + } +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 4e5440ef5..084ec2d0d 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -117,6 +117,23 @@ services: volumes: - ./docker/phpmyadmin/.htaccess:/var/www/html/.htaccess + # Redis Service + ogamex-redis: + image: redis:8.4-alpine + container_name: ogamex-redis + restart: unless-stopped + # Redis port is not exposed to the host machine in production. + volumes: + - ogame-redisdata:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - app-network + # Docker Networks networks: app-network: @@ -126,3 +143,5 @@ networks: volumes: ogame-dbdata: driver: local + ogame-redisdata: + driver: local diff --git a/docker-compose.yml b/docker-compose.yml index 85cd516bc..76138aa92 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -117,6 +117,24 @@ services: networks: - app-network + # Redis Service + ogamex-redis: + image: redis:8.4-alpine + container_name: ogamex-redis + restart: unless-stopped + ports: + - "6379:6379" + volumes: + - ogame-redisdata:/data + command: redis-server --appendonly yes + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 5 + networks: + - app-network + # Docker Networks networks: app-network: @@ -126,3 +144,5 @@ networks: volumes: ogame-dbdata: driver: local + ogame-redisdata: + driver: local diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index f4bccb2d5..4eeebf370 100644 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -27,7 +27,7 @@ if [ "$role" = "scheduler" ]; then sleep 60 done elif [ "$role" = "queue" ]; then - php /var/www/html/artisan queue:work --verbose --no-interaction + php /var/www/html/artisan queue:work --sleep=0.5 --verbose --no-interaction elif [ "$role" = "app" ]; then # Check APP_ENV and run appropriate composer install if [ "$is_production" = true ]; then diff --git a/tests/Feature/PhalanxTest.php b/tests/Feature/PhalanxTest.php index a3c3a89db..8c0a0edd9 100644 --- a/tests/Feature/PhalanxTest.php +++ b/tests/Feature/PhalanxTest.php @@ -310,7 +310,6 @@ public function testScanShowsYourFleetLabelForTransport(): void ]); $response->assertStatus(200); - $this->assertEquals(1, $response->json('fleet_count')); // Check that it shows "Your fleet" $contentHtml = $response->json('content_html');