-
Notifications
You must be signed in to change notification settings - Fork 34
intoduce new async_slow queue for image deletion #2199
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
8180a76
e720933
da717ba
f1cbdc5
bce137e
d8cba5a
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Message\Contracts; | ||
|
|
||
| interface AsyncSlowMessageInterface extends MessageInterface | ||
| { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,37 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\Message; | ||
|
|
||
| use App\Entity\Image; | ||
| use App\Message\Contracts\AsyncSlowMessageInterface; | ||
|
|
||
| /** | ||
| * Will check for every provided image if it is not referenced anywhere | ||
| * and if this is the case deletes the entity from the database & the file from storage. | ||
| */ | ||
| class DeleteImageV2Message implements AsyncSlowMessageInterface | ||
| { | ||
| /** | ||
| * @param array<string, ?string> $images a list of mappings where the key is the sha256 (hex) of the image entity and the value is its filepath | ||
| */ | ||
| public function __construct( | ||
| public array $images, | ||
| ) { | ||
| } | ||
|
|
||
| public static function fromImage(Image $img): self | ||
| { | ||
| return new self([bin2hex($img->sha256) => $img->filePath]); | ||
| } | ||
|
|
||
| public static function fromImages(array $imgs): self | ||
| { | ||
| $content = []; | ||
| foreach ($imgs as $img) { | ||
| $content[bin2hex($img->sha256)] = $img->filePath; | ||
| } | ||
| return new self($content); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,96 @@ | ||
| <?php | ||
|
|
||
| declare(strict_types=1); | ||
|
|
||
| namespace App\MessageHandler; | ||
|
|
||
| use App\Entity\Image; | ||
| use App\Message\DeleteImageV2Message; | ||
| use App\Repository\ImageRepository; | ||
| use App\Service\ImageManagerInterface; | ||
| use Doctrine\ORM\EntityManagerInterface; | ||
| use Psr\Log\LoggerInterface; | ||
| use Symfony\Component\HttpKernel\KernelInterface; | ||
| use Symfony\Component\Messenger\Attribute\AsMessageHandler; | ||
|
|
||
| #[AsMessageHandler] | ||
| readonly class DeleteImageV2Handler | ||
| { | ||
| private int $chunkSize; | ||
|
|
||
| public function __construct( | ||
| KernelInterface $kernel, | ||
| private EntityManagerInterface $entityManager, | ||
| private ImageRepository $imageRepository, | ||
| private ImageManagerInterface $imageManager, | ||
| private LoggerInterface $logger, | ||
| ) { | ||
| $this->chunkSize = ('test' !== $kernel->getEnvironment()) ? 256 : 2; | ||
| } | ||
|
|
||
| public function __invoke(DeleteImageV2Message $message): void | ||
| { | ||
| try { | ||
| /* | ||
| * Split workload into chunks to balance overhead with probability of race-conditions. A race-condition can look like the following: | ||
| * 1. one image in a chunk is detected as an orphan | ||
| * 2. entity is deleted and transaction commited | ||
| * 3. new content is created with the same image (resulting in the same file path) while $filesToDelete is iterated | ||
| * 4. the file is deleted; the new image entity created by the new content now has an invalid path | ||
| */ | ||
| $batches = array_chunk($message->images, $this->chunkSize, true); | ||
| foreach ($batches as $batch) { | ||
| $this->processBatch($batch); | ||
| } | ||
| } finally { | ||
| gc_collect_cycles(); | ||
| } | ||
| } | ||
|
|
||
| private function processBatch(array $batch): void | ||
| { | ||
| $conn = $this->entityManager->getConnection(); | ||
| $conn->getNativeConnection(); // calls connect() internally | ||
|
|
||
| /** @var string[] $filesToDelete */ | ||
| $filesToDelete = $conn->transactional(function () use ($batch) { | ||
| $filesToDelete = []; | ||
| $hashes = array_map(fn ($str) => hex2bin($str), array_keys($batch)); | ||
| $images = $this->imageRepository->findMultipleBySha256AndLock($hashes); | ||
|
|
||
| // images which not exist in DB can be deleted | ||
| foreach ($batch as $hash => $filepath) { | ||
| $hashBin = hex2bin($hash); | ||
| if (!array_any($images, fn ($img) => $img->sha256 === $hashBin)) { | ||
| $filesToDelete[] = $filepath; | ||
| } | ||
| } | ||
|
|
||
| // images which are not referenced can be deleted | ||
| $referenced = $this->imageRepository->areImagesReferenced($images); | ||
| foreach ($referenced as $imgId => $isReferenced) { | ||
| if (!$isReferenced) { | ||
| $img = array_find($images, fn ($img) => $img->getId() === $imgId); | ||
| \assert($img instanceof Image); | ||
|
|
||
| $filesToDelete[] = $img->filePath; | ||
| $this->entityManager->remove($img); | ||
| } | ||
| } | ||
|
|
||
| $this->entityManager->flush(); | ||
| return $filesToDelete; | ||
| }); | ||
|
|
||
| foreach ($filesToDelete as $path) { | ||
| try { | ||
| $this->imageManager->remove($path); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Handle nullable image paths before calling remove For images whose |
||
| } catch (\Exception $e) { | ||
| $this->logger->error('[DeleteImageV2Handler]: an error occurred when deleting an image file: {type} - {message}', [ | ||
| 'message' => $e->getMessage(), | ||
| 'type' => \get_class($e), | ||
| ]); | ||
| } | ||
| } | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -8,6 +8,7 @@ | |
| use App\Entity\User; | ||
| use App\Message\ActivityPub\Outbox\DeliverMessage; | ||
| use App\Message\Contracts\MessageInterface; | ||
| use App\Message\DeleteImageV2Message; | ||
| use App\Message\DeleteUserMessage; | ||
| use App\Service\ActivityPub\ActivityJsonBuilder; | ||
| use App\Service\ActivityPub\Wrapper\DeleteWrapper; | ||
|
|
@@ -83,13 +84,11 @@ public function doWork(MessageInterface $message): void | |
| } catch (\Exception|\Error $e) { | ||
| $this->logger->error("[ClearDeletedUserHandler::__invoke] Couldn't delete the cover of {user} at '{path}': {message}", ['user' => $user->username, 'path' => $user->cover?->filePath, 'message' => \get_class($e).': '.$e->getMessage()]); | ||
| } | ||
| $filePathsOfUser = $this->userManager->getAllImageFilePathsOfUser($user); | ||
| foreach ($filePathsOfUser as $path) { | ||
| try { | ||
| $this->imageManager->remove($path); | ||
| } catch (\Exception|\Error $e) { | ||
| $this->logger->error("[ClearDeletedUserHandler::__invoke] Couldn't delete image of {user} at '{path}': {message}", ['user' => $user->username, 'path' => $path, 'message' => \get_class($e).': '.$e->getMessage()]); | ||
| } | ||
|
|
||
| $imagesOfUser = $this->userManager->getAllImagesShaAndFilepathOfUser($user); | ||
| $deleteImagesPayload = []; | ||
| foreach ($imagesOfUser as $row) { | ||
| $deleteImagesPayload[$row[0]] = $row[1]; | ||
| } | ||
|
|
||
| $this->entityManager->beginTransaction(); | ||
|
|
@@ -122,6 +121,10 @@ public function doWork(MessageInterface $message): void | |
|
|
||
| throw $e; | ||
| } | ||
|
|
||
| // dispatch at end or else reference-check would keep images | ||
| // because of the reference check this call can be safely placed outside the try{} | ||
| $this->bus->dispatch(new DeleteImageV2Message($deleteImagesPayload)); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Dispatch image cleanup atomically with user deletion If transport dispatch fails after the database commit, retrying |
||
| } | ||
|
|
||
| private function sendDeleteMessages(array $targetInboxes, User $deletedUser): void | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Keep the database lock until the file deletion is safe
When the same image is uploaded between the transaction ending on line 83 and this loop,
ImageRepository::create()can create/reuse an entity whose content-addressed path is in$filesToDelete, and this worker then removes that newly referenced file. The absent-row branch is even unprotected because no row can be locked. This concurrency window can leave newly created content pointing at a missing image, so the deletion needs coordination that remains valid through the storage removal.