diff --git a/app/Console/Commands/PruneBacklogEntriesCommand.php b/app/Console/Commands/PruneBacklogEntriesCommand.php index e5dd5c0..536bef7 100644 --- a/app/Console/Commands/PruneBacklogEntriesCommand.php +++ b/app/Console/Commands/PruneBacklogEntriesCommand.php @@ -10,6 +10,8 @@ use Illuminate\Console\Command; use Illuminate\Database\Eloquent\Builder; use Illuminate\Support\Facades\Config; +use Illuminate\Support\Facades\DB; +use Illuminate\Support\Number; /** * Empties the payload columns of entries old enough that nobody will read @@ -56,21 +58,50 @@ public function handle(): int ->whereNotNull('finished_at') ->where('finished_at', '<', $cutoff); + // Measured before the UPDATE empties it, and in SQL — only the sum + // crosses into PHP, never a payload. + $freeing = self::payloadBytes(clone $matching); + $pruned = $this->option('pretend') ? $matching->count() : self::prune($matching); $this->components->info(sprintf( - '%s %d %s finished before %s.', + '%s %d %s finished before %s, freeing %s of payload.', $this->option('pretend') ? 'Would prune' : 'Pruned', $pruned, $pruned === 1 ? 'entry' : 'entries', $cutoff->toDateTimeString(), + Number::fileSize($freeing), )); return self::SUCCESS; } + /** + * How many bytes of request and response bodies the matching rows hold. + * + * The number an operator acts on: Postgres reuses the space on its own + * but only VACUUM FULL hands it back to the filesystem, so "freeing 3 GB" + * says what tonight's run was worth where a before/after of the file + * size would read as the prune having done nothing. + * + * @param Builder $matching + */ + private static function payloadBytes(Builder $matching): int + { + // The cast is Postgres needing text before it will measure json; + // SQLite stores the column as text to begin with. + $length = match (DB::connection()->getDriverName()) { + 'pgsql' => 'coalesce(length(request::text), 0) + coalesce(length(output::text), 0)', + default => 'coalesce(length(request), 0) + coalesce(length(output), 0)', + }; + + $bytes = $matching->toBase()->selectRaw("coalesce(sum({$length}), 0) as bytes")->value('bytes'); + + return is_numeric($bytes) ? (int) $bytes : 0; + } + /** * One UPDATE over the base query, not a model loop: hydrating a payload * in order to throw it away is the exact cost this command exists to end, diff --git a/tests/Feature/Console/PruneBacklogEntriesCommandTest.php b/tests/Feature/Console/PruneBacklogEntriesCommandTest.php index ce4e736..e24c6ea 100644 --- a/tests/Feature/Console/PruneBacklogEntriesCommandTest.php +++ b/tests/Feature/Console/PruneBacklogEntriesCommandTest.php @@ -12,6 +12,8 @@ $before = $entry->fresh()->updated_at->toDateTimeString(); + // One substring, not one per claim: the whole report is a single console + // write, and PendingCommand lets each write satisfy only one expectation. $this->artisan('backlog:prune') ->expectsOutputToContain('Pruned 1 entry finished before') ->assertSuccessful(); @@ -89,3 +91,18 @@ expect($events->filter(fn ($event): bool => str_contains((string) $event->command, 'backlog:prune'))) ->toHaveCount(1); }); + +it('reports how much payload the run frees', function () { + // A hand-sized payload, so the byte count in the report is exact: + // {"k":"v"} is nine bytes, and there is no output to add to it. + BacklogEntry::factory()->create([ + 'status' => 'completed', + 'finished_at' => now()->subDays(8), + 'request' => ['k' => 'v'], + 'output' => null, + ]); + + $this->artisan('backlog:prune') + ->expectsOutputToContain('freeing 9 B of payload') + ->assertSuccessful(); +});