diff --git a/.dockerignore b/.dockerignore index 5671e6a..4a9fed3 100644 --- a/.dockerignore +++ b/.dockerignore @@ -11,3 +11,4 @@ /Justfile /storage/ /vendor/ +/bootstrap/cache/*.php diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 3fbff3a..a8b6f0f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -35,6 +35,11 @@ jobs: --tag $DOCKER_REGISTRY_IMAGE:${PHP_VERSION} \ . + - name: Run application tests + env: + PHP_VERSION: ${{ matrix.php-version }} + run: docker run --rm $DOCKER_REGISTRY_IMAGE:${PHP_VERSION} php artisan test + # - name: Cache vendor folder # uses: actions/cache@v3 # with: diff --git a/README.md b/README.md index 69aabc0..ee7d523 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,13 @@ just start just test ``` +The PHPUnit suite runs in CI against PHP 8.3, 8.4, and 8.5. To run it in a +built container: + +```bash +docker compose run --rm php83 php artisan test +``` + ## Deployment This repo is deployed to apps in all regions on the old platform. Further documentation: @@ -19,3 +26,17 @@ https://github.com/fortrabbit/knowledge-base/blob/main/Old%20platform/Overview/t ### Initial setup for a new App The app expects an environment variable: `APP_TYPE`. Possible values are `uni` or `pro`. + +### Log volume generator + +Visit `/log-volume` to start, monitor, and stop a log-volume run. The UI stores +run state in MySQL and dispatches short chained jobs through TestRabbit's +existing database queue. A normal worker must be running: + +```bash +php artisan queue:work --sleep=5 +``` + +Run `php artisan migrate --force` after deploying the feature. This also adds +the standard `jobs` table required by TestRabbit's configured database queue if +the platform database does not already have it. diff --git a/app/Http/Controllers/LogVolumeController.php b/app/Http/Controllers/LogVolumeController.php new file mode 100644 index 0000000..932bf59 --- /dev/null +++ b/app/Http/Controllers/LogVolumeController.php @@ -0,0 +1,145 @@ +json([ + 'run' => $this->serialize(LogVolumeRun::latest('id')->first()), + ]); + } + + public function start(Request $request): JsonResponse + { + $values = $request->validate([ + 'target_size' => ['required', 'string', 'max:32'], + 'bytes_per_second' => ['required', 'integer', 'min:1', 'max:100000000'], + 'payload_bytes' => ['required', 'integer', 'min:48', 'max:65536'], + 'run_id' => ['nullable', 'string', 'max:100', 'regex:/^[A-Za-z0-9._-]+$/'], + ]); + + $targetBytes = $this->parseBytes($values['target_size'], 10_000_000_000); + $progressBytes = min(100_000_000, $targetBytes); + + $run = DB::transaction(function () use ($values, $targetBytes, $progressBytes) { + $active = LogVolumeRun::whereIn('status', ['queued', 'running', 'cancelling']) + ->lockForUpdate() + ->first(); + + if ($active) { + throw ValidationException::withMessages([ + 'run' => 'A log-volume run is already active.', + ]); + } + + return LogVolumeRun::create([ + 'run_id' => $values['run_id'] ?: bin2hex(random_bytes(8)), + 'status' => 'queued', + 'target_bytes' => $targetBytes, + 'bytes_per_second' => $values['bytes_per_second'], + 'payload_bytes' => $values['payload_bytes'], + 'progress_bytes' => $progressBytes, + 'written_bytes' => 0, + 'lines' => 0, + ]); + }); + + try { + GenerateLogVolume::dispatch($run->id); + } catch (Throwable $exception) { + $run->forceFill([ + 'status' => 'failed', + 'error' => 'Unable to queue the worker job: '.$exception->getMessage(), + 'finished_at' => now(), + ])->save(); + + return response()->json([ + 'message' => 'Unable to queue the log-volume run.', + 'run' => $this->serialize($run), + ], 500); + } + + return response()->json(['run' => $this->serialize($run)], 202); + } + + public function stop(LogVolumeRun $run): JsonResponse + { + if ($run->isActive()) { + $run->forceFill([ + 'status' => 'cancelled', + 'cancel_requested_at' => $run->cancel_requested_at ?? now(), + 'finished_at' => now(), + ])->save(); + } + + return response()->json(['run' => $this->serialize($run->fresh())]); + } + + private function parseBytes(string $value, int $maximum): int + { + if (! preg_match('/^(\d+(?:\.\d+)?)\s*(b|kb|mb|gb|kib|mib|gib)?$/i', trim($value), $matches)) { + throw ValidationException::withMessages([ + 'size' => 'Use a size such as 200mb, 1gb, or 2gib.', + ]); + } + + $multiplier = match (strtolower($matches[2] ?? 'b')) { + 'b' => 1, + 'kb' => 1_000, + 'mb' => 1_000_000, + 'gb' => 1_000_000_000, + 'kib' => 1_024, + 'mib' => 1_048_576, + 'gib' => 1_073_741_824, + }; + $bytes = (int) round((float) $matches[1] * $multiplier); + + if ($bytes < 1 || $bytes > $maximum) { + throw ValidationException::withMessages([ + 'size' => 'The requested size must be between 1 byte and '.number_format($maximum).' bytes.', + ]); + } + + return $bytes; + } + + private function serialize(?LogVolumeRun $run): ?array + { + if (! $run) { + return null; + } + + return [ + 'id' => $run->id, + 'run_id' => $run->run_id, + 'status' => $run->status, + 'target_bytes' => $run->target_bytes, + 'bytes_per_second' => $run->bytes_per_second, + 'payload_bytes' => $run->payload_bytes, + 'progress_bytes' => $run->progress_bytes, + 'written_bytes' => $run->written_bytes, + 'lines' => $run->lines, + 'error' => $run->error, + 'cancel_requested_at' => $run->cancel_requested_at?->toIso8601String(), + 'started_at' => $run->started_at?->toIso8601String(), + 'finished_at' => $run->finished_at?->toIso8601String(), + 'created_at' => $run->created_at?->toIso8601String(), + ]; + } +} diff --git a/app/Jobs/GenerateLogVolume.php b/app/Jobs/GenerateLogVolume.php new file mode 100644 index 0000000..6812379 --- /dev/null +++ b/app/Jobs/GenerateLogVolume.php @@ -0,0 +1,173 @@ +runId); + if (! $run || ! $run->isActive()) { + return; + } + + if ($run->cancel_requested_at) { + $this->finish($run, 'cancelled'); + return; + } + + if ($run->status === 'queued') { + $run->forceFill([ + 'status' => 'running', + 'started_at' => now(), + ])->save(); + } + + $chunkStartedAt = hrtime(true); + $lastCancelCheckAt = $chunkStartedAt; + $chunkWritten = 0; + $chunkLines = 0; + $nextProgressAt = (intdiv($run->written_bytes, $run->progress_bytes) + 1) * $run->progress_bytes; + + while ($run->written_bytes < $run->target_bytes) { + $now = hrtime(true); + if (($now - $chunkStartedAt) >= self::CHUNK_SECONDS * 1_000_000_000) { + break; + } + + if (($now - $lastCancelCheckAt) >= self::CANCEL_CHECK_MICROSECONDS * 1_000) { + if ($this->cancellationRequested($run)) { + $this->finish($run, 'cancelled'); + return; + } + $lastCancelCheckAt = $now; + } + + $line = sprintf( + "log-volume-test run_id=%s sequence=%d emitted_at=%s payload=%s\n", + $run->run_id, + $run->lines, + gmdate('Y-m-d\TH:i:s\Z'), + base64_encode(random_bytes($run->payload_bytes)), + ); + + $this->writeAll(STDERR, $line); + $length = strlen($line); + $run->written_bytes += $length; + ++$run->lines; + $chunkWritten += $length; + ++$chunkLines; + + if ($run->written_bytes >= $nextProgressAt) { + $run->save(); + $this->writeProgress($run); + $nextProgressAt = (intdiv($run->written_bytes, $run->progress_bytes) + 1) * $run->progress_bytes; + } + + $expectedSeconds = max( + $chunkWritten / $run->bytes_per_second, + $chunkLines / self::MAX_LINES_PER_SECOND, + ); + $actualSeconds = (hrtime(true) - $chunkStartedAt) / 1_000_000_000; + $delay = (int) (($expectedSeconds - $actualSeconds) * 1_000_000); + if ($delay > 0) { + usleep(min($delay, 1_000_000)); + } + } + + $run->save(); + + if ($run->written_bytes >= $run->target_bytes) { + $this->finish($run, 'complete'); + return; + } + + self::dispatch($run->id); + } + + protected function cancellationRequested(LogVolumeRun $run): bool + { + return $run->newQuery() + ->whereKey($run->getKey()) + ->whereNotNull('cancel_requested_at') + ->exists(); + } + + public function failed(?Throwable $exception): void + { + LogVolumeRun::whereKey($this->runId) + ->whereIn('status', ['queued', 'running', 'cancelling']) + ->update([ + 'status' => 'failed', + 'error' => $exception?->getMessage() ?? 'The worker job failed.', + 'finished_at' => now(), + ]); + } + + private function finish(LogVolumeRun $run, string $status): void + { + $run->forceFill([ + 'status' => $status, + 'finished_at' => now(), + ])->save(); + + $elapsed = max($run->started_at?->diffInMilliseconds(now()) / 1000, 0.001); + $this->writeAll(STDERR, sprintf( + "log-volume-finished run_id=%s status=%s bytes=%d lines=%d elapsed_seconds=%.3f average_bytes_per_second=%.0f\n", + $run->run_id, + $status, + $run->written_bytes, + $run->lines, + $elapsed, + $run->written_bytes / $elapsed, + )); + } + + private function writeProgress(LogVolumeRun $run): void + { + $elapsed = max($run->started_at?->diffInMilliseconds(now()) / 1000, 0.001); + $this->writeAll(STDERR, sprintf( + "log-volume-progress run_id=%s bytes=%d target_bytes=%d elapsed_seconds=%.3f average_bytes_per_second=%.0f\n", + $run->run_id, + $run->written_bytes, + $run->target_bytes, + $elapsed, + $run->written_bytes / $elapsed, + )); + } + + /** @param resource $stream */ + private function writeAll($stream, string $contents): void + { + $length = strlen($contents); + $offset = 0; + while ($offset < $length) { + $written = fwrite($stream, substr($contents, $offset)); + if ($written === false || $written === 0) { + throw new RuntimeException('Unable to write generated log data.'); + } + $offset += $written; + } + } +} diff --git a/app/Models/LogVolumeRun.php b/app/Models/LogVolumeRun.php new file mode 100644 index 0000000..494f0ab --- /dev/null +++ b/app/Models/LogVolumeRun.php @@ -0,0 +1,27 @@ + 'integer', + 'bytes_per_second' => 'integer', + 'payload_bytes' => 'integer', + 'progress_bytes' => 'integer', + 'written_bytes' => 'integer', + 'lines' => 'integer', + 'cancel_requested_at' => 'datetime', + 'started_at' => 'datetime', + 'finished_at' => 'datetime', + ]; + + public function isActive(): bool + { + return in_array($this->status, ['queued', 'running', 'cancelling'], true); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 4b327d2..5304386 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,6 +17,6 @@ }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( - fn (Request $request) => $request->is('api/*'), + fn (Request $request) => $request->is('api/*') || $request->expectsJson(), ); })->create(); diff --git a/composer.json b/composer.json index 501c06c..b81c5cf 100644 --- a/composer.json +++ b/composer.json @@ -18,7 +18,9 @@ }, "require-dev": { "laravel/sail": "^1.41", - "nunomaduro/collision": "^8.6" + "mockery/mockery": "^1.6", + "nunomaduro/collision": "^8.6", + "phpunit/phpunit": "^12.0" }, "autoload": { "psr-4": { diff --git a/config/fortrabbit.php b/config/fortrabbit.php index 0d60a4a..ab9f9bb 100644 --- a/config/fortrabbit.php +++ b/config/fortrabbit.php @@ -1,10 +1,10 @@ env('FRBIT_PLATFORM', PLATFORM_UBUNTU18) diff --git a/database/migrations/2026_07_28_000000_create_log_volume_runs_table.php b/database/migrations/2026_07_28_000000_create_log_volume_runs_table.php new file mode 100644 index 0000000..dddc946 --- /dev/null +++ b/database/migrations/2026_07_28_000000_create_log_volume_runs_table.php @@ -0,0 +1,33 @@ +id(); + $table->string('run_id', 100)->unique(); + $table->string('status', 20)->index(); + $table->unsignedBigInteger('target_bytes'); + $table->unsignedBigInteger('bytes_per_second'); + $table->unsignedInteger('payload_bytes'); + $table->unsignedBigInteger('progress_bytes'); + $table->unsignedBigInteger('written_bytes')->default(0); + $table->unsignedBigInteger('lines')->default(0); + $table->text('error')->nullable(); + $table->timestamp('cancel_requested_at')->nullable(); + $table->timestamp('started_at')->nullable(); + $table->timestamp('finished_at')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('log_volume_runs'); + } +}; diff --git a/database/migrations/2026_07_28_000001_create_jobs_table.php b/database/migrations/2026_07_28_000001_create_jobs_table.php new file mode 100644 index 0000000..af3431c --- /dev/null +++ b/database/migrations/2026_07_28_000001_create_jobs_table.php @@ -0,0 +1,32 @@ +bigIncrements('id'); + $table->string('queue')->index(); + $table->longText('payload'); + $table->unsignedTinyInteger('attempts'); + $table->unsignedInteger('reserved_at')->nullable(); + $table->unsignedInteger('available_at'); + $table->unsignedInteger('created_at'); + }); + } + + public function down(): void + { + // Some TestRabbit installations provisioned this shared queue table + // outside the repository. Do not risk dropping an existing table on + // rollback in a region where up() was intentionally a no-op. + } +}; diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..e938d0b --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,27 @@ + + + + + tests/Feature + + + + + app + + + + + + + + + + + + + + diff --git a/resources/views/index.blade.php b/resources/views/index.blade.php index 76e5388..a34c9db 100644 --- a/resources/views/index.blade.php +++ b/resources/views/index.blade.php @@ -73,6 +73,10 @@ class="bg-white mb-4 p-4 rounded-lg" PHP error tests +
  • + Log volume generator + +
  • PHP plan scaling — load probe diff --git a/resources/views/log-volume.blade.php b/resources/views/log-volume.blade.php new file mode 100644 index 0000000..caa13b7 --- /dev/null +++ b/resources/views/log-volume.blade.php @@ -0,0 +1,95 @@ + + + + + + + TestRabbit — Log volume + + + + + + +
    +
    +
    +

    Log volume generator

    + ← back to tests +
    + +
    +

    The web process only queues and controls the run. Log data is emitted by the existing Laravel queue worker.

    +
    + + + + +
    +
    + + +
    +
    +
    + +
    +
    + + +
    +
    +
    +
    +
    + / + lines · % +
    +
    +
    + +
    + Worker requirement: the existing php artisan queue:work --sleep=5 process must be running. Output is capped at 12 lines per second to stay below the platform suppression threshold. +
    +
    +
    + + + diff --git a/routes/web.php b/routes/web.php index f0d29d9..2d5ec8b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,6 +1,7 @@ get('/log-volume')->assertOk(); + } + + public function test_start_creates_a_run_and_dispatches_it(): void + { + Queue::fake(); + + $response = $this->postJson('/log-volume/start', $this->validPayload()); + + $response + ->assertAccepted() + ->assertJsonPath('run.status', 'queued') + ->assertJsonPath('run.target_bytes', 2_000_000); + + $run = LogVolumeRun::sole(); + $this->assertSame('test-run', $run->run_id); + Queue::assertPushed( + GenerateLogVolume::class, + fn (GenerateLogVolume $job): bool => $job->runId === $run->id, + ); + } + + public function test_start_validates_sizes_and_worker_settings(): void + { + Queue::fake(); + + $this->postJson('/log-volume/start', [ + 'target_size' => 'lots', + 'bytes_per_second' => 0, + 'payload_bytes' => 12, + 'run_id' => 'spaces are invalid', + ])->assertUnprocessable(); + + $this->assertDatabaseCount('log_volume_runs', 0); + Queue::assertNothingPushed(); + } + + public function test_start_rejects_a_second_active_run(): void + { + Queue::fake(); + $this->createRun(['status' => 'running']); + + $this->postJson('/log-volume/start', $this->validPayload()) + ->assertUnprocessable() + ->assertJsonValidationErrors('run'); + + $this->assertDatabaseCount('log_volume_runs', 1); + Queue::assertNothingPushed(); + } + + public function test_stop_immediately_cancels_an_active_run(): void + { + $run = $this->createRun(['status' => 'cancelling']); + + $this->postJson("/log-volume/{$run->id}/stop") + ->assertOk() + ->assertJsonPath('run.status', 'cancelled'); + + $run->refresh(); + $this->assertSame('cancelled', $run->status); + $this->assertNotNull($run->cancel_requested_at); + $this->assertNotNull($run->finished_at); + } + + public function test_status_returns_the_latest_run(): void + { + $this->createRun(['run_id' => 'older']); + $latest = $this->createRun(['run_id' => 'latest', 'status' => 'complete']); + + $this->getJson('/log-volume/status') + ->assertOk() + ->assertJsonPath('run.id', $latest->id) + ->assertJsonPath('run.run_id', 'latest') + ->assertJsonPath('run.status', 'complete'); + } + + public function test_cancellation_check_preserves_unsaved_progress(): void + { + $run = $this->createRun(['status' => 'running']); + $run->written_bytes = 400_000; + $run->lines = 6; + + $job = new class($run->id) extends GenerateLogVolume + { + public function isCancellationRequested(LogVolumeRun $run): bool + { + return $this->cancellationRequested($run); + } + }; + + $this->assertFalse($job->isCancellationRequested($run)); + $this->assertSame(400_000, $run->written_bytes); + $this->assertSame(6, $run->lines); + + LogVolumeRun::whereKey($run->id)->update(['cancel_requested_at' => now()]); + + $this->assertTrue($job->isCancellationRequested($run)); + $this->assertSame(400_000, $run->written_bytes); + $this->assertSame(6, $run->lines); + } + + private function validPayload(): array + { + return [ + 'target_size' => '2mb', + 'bytes_per_second' => 2_000_000, + 'payload_bytes' => 768, + 'run_id' => 'test-run', + ]; + } + + private function createRun(array $attributes = []): LogVolumeRun + { + return LogVolumeRun::create(array_merge([ + 'run_id' => bin2hex(random_bytes(8)), + 'status' => 'queued', + 'target_bytes' => 2_000_000, + 'bytes_per_second' => 2_000_000, + 'payload_bytes' => 768, + 'progress_bytes' => 1_000_000, + 'written_bytes' => 0, + 'lines' => 0, + ], $attributes)); + } +} diff --git a/tests/TestCase.php b/tests/TestCase.php new file mode 100644 index 0000000..539c7dd --- /dev/null +++ b/tests/TestCase.php @@ -0,0 +1,9 @@ +