Files
Gnuboard7/tests/Unit/Installer/WorkerLockGuardTest.php
2026-07-01 10:30:32 +09:00

197 lines
7.7 KiB
PHP

<?php
namespace Tests\Unit\Installer;
use PHPUnit\Framework\TestCase;
/**
* 워커 동시 실행 방지 lock/heartbeat 가드 회귀 테스트
*
* 시나리오 B (race condition 본질 차단):
* - 환경 (예: mod_fcgid + FcgidOutputBufferSize 미설정) 에서 SSE 가 client 까지
* 도달 못해 사용자가 폴링으로 풀백. 그 사이 SSE 워커는 백그라운드 진행.
* - 새 폴링 워커가 진입하면 두 워커가 동일 인스톨러 state + DB 를 동시 조작.
* - 본 가드: state.active_worker_id + state.last_heartbeat 로 동시 실행 차단.
*
* 검증 대상 헬퍼:
* - acquireWorkerLock(int $staleSeconds): ['acquired'=>bool, 'worker_id'=>string|null, 'reason'=>string]
* - refreshWorkerHeartbeat(string $workerId): bool — 자기 worker_id 일 때만 true
* - releaseWorkerLock(string $workerId): void
*
* 본 테스트는 헬퍼 자체의 결정론적 동작만 검증. 실제 두 PHP 프로세스 동시 실행은
* 매뉴얼 시나리오 검증 영역.
*/
class WorkerLockGuardTest extends TestCase
{
private string $tempBase = '';
private string $stateFile = '';
private string $skipReason = '';
protected function setUp(): void
{
parent::setUp();
// 안전 가드: 본 테스트는 BASE_PATH/storage/installer-state.json 을 write/unlink 한다.
// BASE_PATH 는 PHP 상수라 한 프로세스에서 단 한 번만 정의되며, 다른 Installer 테스트가
// 먼저 프로젝트 루트로 박으면 본 테스트가 실제 운영 installer-state.json 을 파괴하게 된다.
// BASE_PATH 가 시스템 temp 하위가 아니면 skip 한다.
$tempPrefix = realpath(sys_get_temp_dir()) ?: sys_get_temp_dir();
if (defined('BASE_PATH')) {
$resolved = realpath((string) BASE_PATH) ?: (string) BASE_PATH;
if (strpos($resolved, $tempPrefix) !== 0) {
$this->skipReason = 'BASE_PATH (' . $resolved . ') 가 시스템 temp 하위가 아님 — '
. '다른 Installer 테스트의 BASE_PATH 정의가 선행됨. 실제 installer-state.json 파괴 방지를 위해 skip. '
. '격리 실행: php vendor/bin/phpunit --filter=WorkerLockGuardTest';
$this->markTestSkipped($this->skipReason);
}
$this->tempBase = (string) BASE_PATH;
} else {
$this->tempBase = sys_get_temp_dir() . '/g7-worker-lock-test-' . bin2hex(random_bytes(4));
define('BASE_PATH', $this->tempBase);
}
if (! is_dir($this->tempBase . '/storage')) {
@mkdir($this->tempBase . '/storage', 0755, true);
}
$this->stateFile = BASE_PATH . '/storage/installer-state.json';
@file_put_contents($this->stateFile, json_encode(['installation_status' => 'pending']));
require_once dirname(__DIR__, 3) . '/public/install/includes/config.php';
require_once dirname(__DIR__, 3) . '/public/install/includes/installer-state.php';
}
protected function tearDown(): void
{
// skip 상태(= BASE_PATH 가 실 루트)면 파괴적 정리를 절대 수행하지 않는다.
if ($this->skipReason === '' && $this->stateFile !== '') {
@unlink($this->stateFile);
}
parent::tearDown();
}
public function test_acquire_succeeds_when_no_active_worker(): void
{
$result = acquireWorkerLock(15);
$this->assertTrue($result['acquired']);
$this->assertNotEmpty($result['worker_id']);
$this->assertSame('available', $result['reason']);
}
public function test_acquire_persists_worker_id_and_heartbeat_in_state(): void
{
$result = acquireWorkerLock(15);
$state = json_decode(file_get_contents($this->stateFile), true);
$this->assertSame($result['worker_id'], $state['active_worker_id']);
$this->assertIsInt($state['last_heartbeat']);
$this->assertGreaterThanOrEqual(time() - 1, $state['last_heartbeat']);
}
public function test_acquire_fails_when_active_worker_within_stale_window(): void
{
$first = acquireWorkerLock(15);
$this->assertTrue($first['acquired']);
// 두 번째 워커 진입 시도 — 활동 중이라 거부되어야 함
$second = acquireWorkerLock(15);
$this->assertFalse($second['acquired']);
$this->assertNull($second['worker_id']);
$this->assertSame('busy', $second['reason']);
// state 의 worker_id 는 첫 번째 그대로
$state = json_decode(file_get_contents($this->stateFile), true);
$this->assertSame($first['worker_id'], $state['active_worker_id']);
}
public function test_acquire_takes_over_when_heartbeat_stale(): void
{
$first = acquireWorkerLock(15);
// heartbeat 를 stale 시점으로 강제 변경
$state = json_decode(file_get_contents($this->stateFile), true);
$state['last_heartbeat'] = time() - 60;
file_put_contents($this->stateFile, json_encode($state));
$second = acquireWorkerLock(15);
$this->assertTrue($second['acquired']);
$this->assertNotSame($first['worker_id'], $second['worker_id']);
$this->assertSame('takeover_stale', $second['reason']);
$newState = json_decode(file_get_contents($this->stateFile), true);
$this->assertSame($second['worker_id'], $newState['active_worker_id']);
}
public function test_refresh_heartbeat_returns_true_for_owner(): void
{
$first = acquireWorkerLock(15);
// 의도적 1초 지연 (heartbeat 갱신 detection)
sleep(1);
$ok = refreshWorkerHeartbeat($first['worker_id']);
$this->assertTrue($ok);
$state = json_decode(file_get_contents($this->stateFile), true);
$this->assertGreaterThanOrEqual(time() - 1, $state['last_heartbeat']);
}
public function test_refresh_heartbeat_returns_false_for_non_owner(): void
{
$first = acquireWorkerLock(15);
// 다른 worker_id 가 takeover 한 상황 시뮬레이션
$state = json_decode(file_get_contents($this->stateFile), true);
$state['active_worker_id'] = 'someone-else';
file_put_contents($this->stateFile, json_encode($state));
$ok = refreshWorkerHeartbeat($first['worker_id']);
$this->assertFalse($ok);
}
public function test_release_clears_worker_id_and_heartbeat(): void
{
$first = acquireWorkerLock(15);
releaseWorkerLock($first['worker_id']);
$state = json_decode(file_get_contents($this->stateFile), true);
$this->assertArrayNotHasKey('active_worker_id', $state);
$this->assertArrayNotHasKey('last_heartbeat', $state);
}
public function test_release_no_op_when_worker_id_does_not_match(): void
{
$first = acquireWorkerLock(15);
// 다른 worker 가 takeover
$state = json_decode(file_get_contents($this->stateFile), true);
$newWorkerId = 'taker-' . bin2hex(random_bytes(4));
$state['active_worker_id'] = $newWorkerId;
file_put_contents($this->stateFile, json_encode($state));
// 원본 worker 가 release 시도 — 자기 worker_id 가 아니므로 무시
releaseWorkerLock($first['worker_id']);
$stateAfter = json_decode(file_get_contents($this->stateFile), true);
$this->assertSame($newWorkerId, $stateAfter['active_worker_id']);
}
public function test_acquire_after_release_succeeds(): void
{
$first = acquireWorkerLock(15);
releaseWorkerLock($first['worker_id']);
$second = acquireWorkerLock(15);
$this->assertTrue($second['acquired']);
$this->assertSame('available', $second['reason']);
$this->assertNotSame($first['worker_id'], $second['worker_id']);
}
}