From a86c7e8bb921bb76f00c7dccd440e9fd0b8e8eef Mon Sep 17 00:00:00 2001 From: Rafik Abdulwahab Date: Tue, 28 Jul 2026 14:08:36 +0200 Subject: [PATCH 1/6] add log-volume-generator --- README.md | 12 ++ app/Http/Controllers/LogVolumeController.php | 131 ++++++++++++++ app/Jobs/GenerateLogVolume.php | 160 ++++++++++++++++++ app/Models/LogVolumeRun.php | 27 +++ ...28_000000_create_log_volume_runs_table.php | 33 ++++ resources/views/index.blade.php | 4 + resources/views/log-volume.blade.php | 89 ++++++++++ routes/web.php | 6 + 8 files changed, 462 insertions(+) create mode 100644 app/Http/Controllers/LogVolumeController.php create mode 100644 app/Jobs/GenerateLogVolume.php create mode 100644 app/Models/LogVolumeRun.php create mode 100644 database/migrations/2026_07_28_000000_create_log_volume_runs_table.php create mode 100644 resources/views/log-volume.blade.php diff --git a/README.md b/README.md index 69aabc0..9038e88 100644 --- a/README.md +++ b/README.md @@ -19,3 +19,15 @@ 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. diff --git a/app/Http/Controllers/LogVolumeController.php b/app/Http/Controllers/LogVolumeController.php new file mode 100644 index 0000000..c123e6c --- /dev/null +++ b/app/Http/Controllers/LogVolumeController.php @@ -0,0 +1,131 @@ +json([ + 'run' => $this->serialize(LogVolumeRun::latest()->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'], + 'progress_size' => ['required', 'string', 'max:32'], + 'run_id' => ['nullable', 'string', 'max:100', 'regex:/^[A-Za-z0-9._-]+$/'], + ]); + + $targetBytes = $this->parseBytes($values['target_size'], 10_000_000_000); + $progressBytes = $this->parseBytes($values['progress_size'], $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, + ]); + }); + + GenerateLogVolume::dispatch($run->id); + + return response()->json(['run' => $this->serialize($run)], 202); + } + + public function stop(LogVolumeRun $run): JsonResponse + { + if ($run->isActive() && ! $run->cancel_requested_at) { + $run->forceFill([ + 'status' => 'cancelling', + 'cancel_requested_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..08c2441 --- /dev/null +++ b/app/Jobs/GenerateLogVolume.php @@ -0,0 +1,160 @@ +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; + $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) { + $run->refresh(); + if ($run->cancel_requested_at) { + $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; + + if ($run->written_bytes >= $nextProgressAt) { + $run->save(); + $this->writeProgress($run); + $nextProgressAt = (intdiv($run->written_bytes, $run->progress_bytes) + 1) * $run->progress_bytes; + } + + $expectedSeconds = $chunkWritten / $run->bytes_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); + } + + 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/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/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..72d9db1 --- /dev/null +++ b/resources/views/log-volume.blade.php @@ -0,0 +1,89 @@ + + + + + + + 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. Large runs are automatically split into short queue jobs so the standard worker timeout remains usable. +
    +
    +
    + + + 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 @@ Date: Tue, 28 Jul 2026 15:08:49 +0200 Subject: [PATCH 2/6] add jobs table --- README.md | 4 ++- app/Http/Controllers/LogVolumeController.php | 23 ++++++++++--- .../2026_07_28_000001_create_jobs_table.php | 32 +++++++++++++++++++ resources/views/log-volume.blade.php | 13 ++++++-- 4 files changed, 64 insertions(+), 8 deletions(-) create mode 100644 database/migrations/2026_07_28_000001_create_jobs_table.php diff --git a/README.md b/README.md index 9038e88..93e128f 100644 --- a/README.md +++ b/README.md @@ -30,4 +30,6 @@ existing database queue. A normal worker must be running: php artisan queue:work --sleep=5 ``` -Run `php artisan migrate --force` after deploying the feature. +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 index c123e6c..006cdb7 100644 --- a/app/Http/Controllers/LogVolumeController.php +++ b/app/Http/Controllers/LogVolumeController.php @@ -9,6 +9,7 @@ use Illuminate\Routing\Controller; use Illuminate\Support\Facades\DB; use Illuminate\Validation\ValidationException; +use Throwable; class LogVolumeController extends Controller { @@ -60,17 +61,31 @@ public function start(Request $request): JsonResponse ]); }); - GenerateLogVolume::dispatch($run->id); + 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->cancel_requested_at) { + if ($run->isActive()) { $run->forceFill([ - 'status' => 'cancelling', - 'cancel_requested_at' => now(), + 'status' => 'cancelled', + 'cancel_requested_at' => $run->cancel_requested_at ?? now(), + 'finished_at' => now(), ])->save(); } 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/resources/views/log-volume.blade.php b/resources/views/log-volume.blade.php index 72d9db1..5d7e0ec 100644 --- a/resources/views/log-volume.blade.php +++ b/resources/views/log-volume.blade.php @@ -29,7 +29,7 @@
    - +
    @@ -71,8 +71,15 @@ function logVolumeApp() { this.busy = true; this.error = ''; try { const response = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', 'X-CSRF-TOKEN': document.querySelector('meta[name=csrf-token]').content }, body: JSON.stringify(body) }); - const data = await response.json(); - if (!response.ok) throw new Error(data.message || Object.values(data.errors || {}).flat().join(' ')); + const text = await response.text(); + let data = {}; + try { data = text ? JSON.parse(text) : {}; } catch (e) { + throw new Error('Server returned HTTP ' + response.status + ' instead of JSON. Check the application log.'); + } + if (!response.ok) { + if (data.run) this.run = data.run; + throw new Error(data.message || Object.values(data.errors || {}).flat().join(' ')); + } this.run = data.run; } catch (e) { this.error = e.message; } finally { this.busy = false; } }, From f3c0ccfa839612e480c5532e9b77ef2b456fa5fa Mon Sep 17 00:00:00 2001 From: Rafik Abdulwahab Date: Tue, 28 Jul 2026 15:20:18 +0200 Subject: [PATCH 3/6] add tests --- .dockerignore | 1 + .github/workflows/deploy.yml | 5 + README.md | 7 ++ app/Http/Controllers/LogVolumeController.php | 2 +- bootstrap/app.php | 2 +- composer.json | 4 +- config/fortrabbit.php | 10 +- phpunit.xml | 27 +++++ tests/Feature/LogVolumeTest.php | 118 +++++++++++++++++++ tests/TestCase.php | 9 ++ 10 files changed, 177 insertions(+), 8 deletions(-) create mode 100644 phpunit.xml create mode 100644 tests/Feature/LogVolumeTest.php create mode 100644 tests/TestCase.php 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 93e128f..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: diff --git a/app/Http/Controllers/LogVolumeController.php b/app/Http/Controllers/LogVolumeController.php index 006cdb7..7a7920e 100644 --- a/app/Http/Controllers/LogVolumeController.php +++ b/app/Http/Controllers/LogVolumeController.php @@ -21,7 +21,7 @@ public function index() public function status(): JsonResponse { return response()->json([ - 'run' => $this->serialize(LogVolumeRun::latest()->first()), + 'run' => $this->serialize(LogVolumeRun::latest('id')->first()), ]); } 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/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/tests/Feature/LogVolumeTest.php b/tests/Feature/LogVolumeTest.php new file mode 100644 index 0000000..10dbda6 --- /dev/null +++ b/tests/Feature/LogVolumeTest.php @@ -0,0 +1,118 @@ +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, + 'progress_size' => '100mb', + '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'); + } + + private function validPayload(): array + { + return [ + 'target_size' => '2mb', + 'bytes_per_second' => 2_000_000, + 'payload_bytes' => 768, + 'progress_size' => '1mb', + '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 @@ + Date: Tue, 28 Jul 2026 15:26:22 +0200 Subject: [PATCH 4/6] cap at 12 lines per second --- app/Http/Controllers/LogVolumeController.php | 3 +-- app/Jobs/GenerateLogVolume.php | 8 +++++++- resources/views/log-volume.blade.php | 5 ++--- tests/Feature/LogVolumeTest.php | 2 -- 4 files changed, 10 insertions(+), 8 deletions(-) diff --git a/app/Http/Controllers/LogVolumeController.php b/app/Http/Controllers/LogVolumeController.php index 7a7920e..932bf59 100644 --- a/app/Http/Controllers/LogVolumeController.php +++ b/app/Http/Controllers/LogVolumeController.php @@ -31,12 +31,11 @@ public function start(Request $request): JsonResponse 'target_size' => ['required', 'string', 'max:32'], 'bytes_per_second' => ['required', 'integer', 'min:1', 'max:100000000'], 'payload_bytes' => ['required', 'integer', 'min:48', 'max:65536'], - 'progress_size' => ['required', 'string', 'max:32'], 'run_id' => ['nullable', 'string', 'max:100', 'regex:/^[A-Za-z0-9._-]+$/'], ]); $targetBytes = $this->parseBytes($values['target_size'], 10_000_000_000); - $progressBytes = $this->parseBytes($values['progress_size'], $targetBytes); + $progressBytes = min(100_000_000, $targetBytes); $run = DB::transaction(function () use ($values, $targetBytes, $progressBytes) { $active = LogVolumeRun::whereIn('status', ['queued', 'running', 'cancelling']) diff --git a/app/Jobs/GenerateLogVolume.php b/app/Jobs/GenerateLogVolume.php index 08c2441..9fe9f9e 100644 --- a/app/Jobs/GenerateLogVolume.php +++ b/app/Jobs/GenerateLogVolume.php @@ -19,6 +19,7 @@ class GenerateLogVolume implements ShouldQueue private const CHUNK_SECONDS = 10; private const CANCEL_CHECK_MICROSECONDS = 500_000; + private const MAX_LINES_PER_SECOND = 12; public function __construct(public int $runId) { @@ -46,6 +47,7 @@ public function handle(): void $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) { @@ -76,6 +78,7 @@ public function handle(): void $run->written_bytes += $length; ++$run->lines; $chunkWritten += $length; + ++$chunkLines; if ($run->written_bytes >= $nextProgressAt) { $run->save(); @@ -83,7 +86,10 @@ public function handle(): void $nextProgressAt = (intdiv($run->written_bytes, $run->progress_bytes) + 1) * $run->progress_bytes; } - $expectedSeconds = $chunkWritten / $run->bytes_per_second; + $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) { diff --git a/resources/views/log-volume.blade.php b/resources/views/log-volume.blade.php index 5d7e0ec..6d3182b 100644 --- a/resources/views/log-volume.blade.php +++ b/resources/views/log-volume.blade.php @@ -24,7 +24,6 @@ -
    @@ -50,14 +49,14 @@
    - Worker requirement: the existing php artisan queue:work --sleep=5 process must be running. Large runs are automatically split into short queue jobs so the standard worker timeout remains usable. + 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.