Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 32 additions & 1 deletion app/Console/Commands/PruneBacklogEntriesCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<BacklogEntry> $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,
Expand Down
17 changes: 17 additions & 0 deletions tests/Feature/Console/PruneBacklogEntriesCommandTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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();
});