The blob prune follows APP_STORAGE_DIR and clears orphaned thumbnails (#234)

app:prune:blobs looked under a hardcoded var/, so installs with another APP_STORAGE_DIR reclaimed nothing; it and the thumbnail cache now use APP_STORAGE_DIR. A third pass deletes attachment thumbnails whose part no longer exists. Closes #232, closes #233.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5.5 authored and GitHub committed 2026-10-19 20:38:24 +02:00
1 parent cdd0cd4fd8
commit 9c76bbc636
10 files changed
+343 -33

No files matched your search

@@ -53,6 +53,7 @@ final class PruneBlobsCommandTest extends TestCase
'var/uploads/avatars/3/3f9c1e.png',
'var/uploads/signatures/3/logo-a1b2c3.png',
'var/uploads/backgrounds/7d2e.jpg',
// A thumbnail of a part that exists: the thumbnail pass's, and kept.
'var/attachments/thumbs/42.webp',
// A store nobody has written yet, in the shape the sealed messages
// of v0.4.0 take: it is left alone without anyone listing it here.
@@ -68,7 +69,7 @@ final class PruneBlobsCommandTest extends TestCase
$this->file($path, self::OLD);
}
$exit = $this->command()->execute([]);
$exit = $this->command(existingParts: [42])->execute([]);
self::assertSame(Command::SUCCESS, $exit);
@@ -147,6 +148,69 @@ final class PruneBlobsCommandTest extends TestCase
self::assertStringContainsString('Orphans in var/raw: 1', $tester->getDisplay());
}
/**
* #233: the stores are under APP_STORAGE_DIR, not a hardcoded var/. On an
* install that moved them, the sweep looked where nothing was written and
* reclaimed nothing.
*/
public function testTheStoresAreUnderTheStorageDirectory(): void
{
$orphan = 'var/data/raw/3/1/1042.eml';
$referenced = 'var/data/attachments/3/12/4711/kept.pdf';
$elsewhere = 'var/raw/3/1/1042.eml';
foreach ([$orphan, $referenced, $elsewhere] as $path) {
$this->file($path, self::OLD);
}
$this->command(parts: [$referenced => true], storageDir: 'var/data')->execute([]);
self::assertFileDoesNotExist($this->projectDir.'/'.$orphan, 'swept where the writers write');
self::assertFileExists($this->projectDir.'/'.$referenced, 'the keep-list is asked with the storage dir\'s prefix');
self::assertFileExists($this->projectDir.'/'.$elsewhere, 'and var/ is not this install\'s store');
}
/**
* #232: a thumbnail is named by its part id, and nothing deletes it with
* the part. It goes once the part is gone, and not before.
*/
public function testThumbnailsOfPartsThatAreGoneAreDeleted(): void
{
$gone = 'var/attachments/thumbs/41.webp';
$existing = 'var/attachments/thumbs/42.webp';
$recent = 'var/attachments/thumbs/43.webp';
$foreign = 'var/attachments/thumbs/notes.txt';
// Larger than any message_part.id can be: not ours, and it must not
// turn the existence query into an error that stops the run.
$tooLarge = 'var/attachments/thumbs/99999999999.webp';
foreach ([$gone, $existing, $foreign, $tooLarge] as $path) {
$this->file($path, self::OLD);
}
$this->file($recent, '-1 day');
$tester = $this->command(existingParts: [42]);
$tester->execute([]);
self::assertFileDoesNotExist($this->projectDir.'/'.$gone);
self::assertFileExists($this->projectDir.'/'.$existing, 'its part still exists');
self::assertFileExists($this->projectDir.'/'.$recent, 'inside the window');
self::assertFileExists($this->projectDir.'/'.$foreign, 'not a thumbnail');
self::assertFileExists($this->projectDir.'/'.$tooLarge, 'not a part id');
self::assertStringContainsString('Orphaned thumbnails: 1', $tester->getDisplay());
}
public function testADryRunKeepsThumbnailsToo(): void
{
$gone = 'var/attachments/thumbs/41.webp';
$this->file($gone, self::OLD);
$this->command()->execute(['--dry-run' => true]);
self::assertFileExists($this->projectDir.'/'.$gone);
}
private function file(string $relativePath, string $age): void
{
$path = $this->projectDir.'/'.$relativePath;
@@ -163,15 +227,24 @@ final class PruneBlobsCommandTest extends TestCase
* @param array<string, true> $uploads
* @param array<string, true> $parts
* @param array<string, true> $raw
* @param list<int> $existingParts part ids that still exist
*/
private function command(array $uploads = [], array $parts = [], array $raw = []): CommandTester
{
private function command(
array $uploads = [],
array $parts = [],
array $raw = [],
array $existingParts = [],
string $storageDir = 'var',
): CommandTester {
$blobs = $this->createStub(UploadedBlobRepository::class);
$blobs->method('findOlderThan')->willReturn([]);
$blobs->method('findReferencedPaths')->willReturn($uploads);
$messageParts = $this->createStub(MessagePartRepository::class);
$messageParts->method('findReferencedStoragePaths')->willReturn($parts);
$messageParts->method('findExistingIds')->willReturnCallback(
static fn (array $ids): array => array_fill_keys(array_values(array_intersect($ids, $existingParts)), true),
);
$messages = $this->createStub(MessageRepository::class);
$messages->method('findReferencedRawPaths')->willReturn($raw);
@@ -180,9 +253,10 @@ final class PruneBlobsCommandTest extends TestCase
$blobs,
$messageParts,
$messages,
new UploadStorage($this->projectDir, 'var'),
new UploadStorage($this->projectDir, $storageDir),
$this->createStub(EntityManagerInterface::class),
$this->projectDir,
$storageDir,
));
}
}
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace App\Tests\Repository\Mail;
use App\Domain\Enum\Mail\LabelRole;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessagePart;
use App\Repository\Mail\MessagePartRepository;
use App\Tests\Support\Mail\SeedsMarkerFixtures;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
/**
* Which part ids still exist: the thumbnail sweep's keep-list (#232).
*
* Against the real database, because the answer decides which files the
* weekly prune deletes: an id it wrongly calls gone costs a thumbnail, and a
* query that comes back empty would cost all of them.
*/
final class MessagePartExistingIdsTest extends KernelTestCase
{
use SeedsMarkerFixtures;
protected function setUp(): void
{
self::bootKernel();
$this->em = static::getContainer()->get(EntityManagerInterface::class);
$this->em->getConnection()->beginTransaction();
$this->user = $this->seedUser();
$this->account = $this->seedAccount();
$this->inbox = $this->seedLabel('Inbox', LabelRole::Inbox);
}
protected function tearDown(): void
{
if (true === $this->em->getConnection()->isTransactionActive()) {
$this->em->getConnection()->rollBack();
}
parent::tearDown();
}
public function testOnlyTheIdsOfPartsThatExistComeBack(): void
{
$first = $this->part();
$second = $this->part();
$gone = $this->part();
$goneId = (int) $gone->id;
$this->em->remove($gone);
$this->em->flush();
$existing = static::getContainer()->get(MessagePartRepository::class)
->findExistingIds([(int) $first->id, (int) $second->id, $goneId, 2_147_483_647]);
self::assertSame([(int) $first->id => true, (int) $second->id => true], $this->sorted($existing));
}
public function testNoIdsAskNothing(): void
{
self::assertSame([], static::getContainer()->get(MessagePartRepository::class)->findExistingIds([]));
}
private function part(): MessagePart
{
/** @var Message $message */
$message = $this->thread('Teile')->messages->first();
$part = new MessagePart();
$part->contentType = 'image/png';
$part->filename = 'p.png';
$part->disposition = 'attachment';
$part->size = 1;
$part->storagePath = 'var/attachments/1/1/1/p.png';
$message->addMessagePart($part);
$this->em->persist($part);
$this->em->flush();
return $part;
}
/**
* @param array<int, true> $set
*
* @return array<int, true>
*/
private function sorted(array $set): array
{
ksort($set);
return $set;
}
}