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 @@
+
+
The web process only queues and controls the run. Log data is emitted by the existing Laravel queue worker.
+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.
+