Skip to content

Commit e58e8f7

Browse files
authored
Harden tests scenarios for deletion (#4003)
1 parent 4112077 commit e58e8f7

File tree

7 files changed

+907
-148
lines changed

7 files changed

+907
-148
lines changed

app/Actions/Album/Delete.php

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,11 @@
88

99
namespace App\Actions\Album;
1010

11-
use App\Actions\Photo\Delete as PhotoDelete;
1211
use App\Actions\Shop\PurchasableService;
1312
use App\Constants\AccessPermissionConstants as APC;
1413
use App\Constants\PhotoAlbum as PA;
15-
use App\DTO\Delete\AlbumsToBeDeleteDTO;
14+
use App\DTO\Delete\AlbumsToBeDeletedDTO;
15+
use App\DTO\Delete\PhotosToBeDeletedDTO;
1616
use App\Enum\StorageDiskType;
1717
use App\Events\AlbumDeleted;
1818
use App\Exceptions\CorruptedTreeException;
@@ -87,11 +87,10 @@ public function do(array $album_ids): void
8787

8888
$this->jobs[] = new FileDeleterJob(StorageDiskType::LOCAL, $albums_to_delete->tracks->all());
8989

90-
$photos_to_delete = $this->findAllPhotosToDelete($albums_to_delete->album_ids);
90+
$photos_to_be_deleted = $this->findAllPhotosToDelete($albums_to_delete->album_ids);
9191

9292
// Nuke the photos.
93-
$photo_delete_action = resolve(PhotoDelete::class);
94-
$jobs = $photo_delete_action->forceDelete($photos_to_delete);
93+
$jobs = $photos_to_be_deleted->executeDelete();
9594

9695
// Nuke the albums.
9796
$albums_to_delete->executeDelete();
@@ -134,9 +133,9 @@ private function deleteTagAlbums(array $tag_album_ids): void
134133
*
135134
* @param string[] $album_ids that are being deleted
136135
*
137-
* @return string[] of photo IDs that can be deleted fully
136+
* @return PhotosToBeDeletedDTO of photo IDs that can be deleted fully
138137
*/
139-
public function findAllPhotosToDelete(array $album_ids): array
138+
public function findAllPhotosToDelete(array $album_ids): PhotosToBeDeletedDTO
140139
{
141140
// First collect which pictures needs to be potentially deleted.
142141
// ! RISK OF MEMOY EXHAUSTION !
@@ -192,17 +191,21 @@ public function findAllPhotosToDelete(array $album_ids): array
192191
$idx_total++;
193192
}
194193

195-
return $photos_to_delete_fully;
194+
return new PhotosToBeDeletedDTO(
195+
force_delete_photo_ids: $photos_to_delete_fully,
196+
soft_delete_photo_ids: $photos_to_detach,
197+
album_ids: $album_ids,
198+
);
196199
}
197200

198201
/**
199202
* Find all albums we want to delete (sub trees).
200203
*
201204
* @param array $album_ids
202205
*
203-
* @return AlbumsToBeDeleteDTO
206+
* @return AlbumsToBeDeletedDTO
204207
*/
205-
private function findAllAlbumsToDelete(array $album_ids): AlbumsToBeDeleteDTO
208+
private function findAllAlbumsToDelete(array $album_ids): AlbumsToBeDeletedDTO
206209
{
207210
// First gather the gaps that will be made:
208211
$gaps = $this->getGaps($album_ids);
@@ -236,7 +239,7 @@ private function findAllAlbumsToDelete(array $album_ids): AlbumsToBeDeleteDTO
236239
// prune the null values
237240
$recursive_album_tracks = $recursive_album_tracks->filter(fn ($val) => $val !== null);
238241

239-
return new AlbumsToBeDeleteDTO(
242+
return new AlbumsToBeDeletedDTO(
240243
parent_ids: $parent_ids,
241244
album_ids: $recursive_album_ids,
242245
tracks: $recursive_album_tracks,

app/Actions/Photo/Delete.php

Lines changed: 8 additions & 136 deletions
Original file line numberDiff line numberDiff line change
@@ -10,16 +10,10 @@
1010

1111
use App\Actions\Shop\PurchasableService;
1212
use App\Constants\PhotoAlbum as PA;
13-
use App\Enum\SizeVariantType;
14-
use App\Enum\StorageDiskType;
13+
use App\DTO\Delete\PhotosToBeDeletedDTO;
1514
use App\Events\PhotoDeleted;
1615
use App\Exceptions\Internal\LycheeLogicException;
17-
use App\Exceptions\Internal\QueryBuilderException;
1816
use App\Exceptions\ModelDBException;
19-
use App\Jobs\FileDeleterJob;
20-
use App\Models\SizeVariant;
21-
use Illuminate\Database\Query\JoinClause;
22-
use Illuminate\Support\Collection;
2317
use Illuminate\Support\Facades\DB;
2418

2519
/**
@@ -53,66 +47,6 @@ public function __construct(
5347
$this->purchasable_service = resolve(PurchasableService::class);
5448
}
5549

56-
/**
57-
* Delete the designated photos.
58-
* There is no check for album dependencies. This has already be done.
59-
* This is a force delete.
60-
*
61-
* @param array $photo_ids Photos to deleted
62-
*
63-
* @return array
64-
*/
65-
public function forceDelete(array $photo_ids): array
66-
{
67-
// We do not check the purchasable service as they have already been deleted by the caller.
68-
if (count($photo_ids) === 0) {
69-
return [];
70-
}
71-
72-
// Reset headers and covers pointing to deleted photos
73-
DB::table('albums')->whereIn('header_id', $photo_ids)->update(['header_id' => null]);
74-
DB::table('albums')->whereIn('cover_id', $photo_ids)->update(['cover_id' => null]);
75-
76-
// Maybe consider doing multiple queries for the different storage types.
77-
$exclude_size_variants_ids = DB::table('order_items')->select(['size_variant_id'])->pluck('size_variant_id')->all();
78-
79-
// Collect size variants to be deleted
80-
// ! Risk of memory exhaustion if too many photos are deleted at once !
81-
$size_variants_local = $this->collectSizeVariantPathsByPhotoID($photo_ids, StorageDiskType::LOCAL, $exclude_size_variants_ids);
82-
$short_paths_local = $size_variants_local->pluck('short_path')->all();
83-
$short_path_watermarked_local = $size_variants_local->pluck('short_path_watermarked')->filter()->all();
84-
85-
$size_variants_s3 = $this->collectSizeVariantPathsByPhotoID($photo_ids, StorageDiskType::S3, $exclude_size_variants_ids);
86-
$short_paths_s3 = $size_variants_s3->pluck('short_path')->all();
87-
$short_path_watermarked_s3 = $size_variants_s3->pluck('short_path_watermarked')->filter()->all();
88-
89-
$live_photo_short_paths_local = $this->collectLivePhotoPathsByPhotoID($photo_ids, StorageDiskType::LOCAL)->pluck('live_photo_short_path')->all();
90-
$live_photo_short_paths_s3 = $this->collectLivePhotoPathsByPhotoID($photo_ids, StorageDiskType::S3)->pluck('live_photo_short_path')->all();
91-
92-
$delete_jobs = [];
93-
$delete_jobs[] = new FileDeleterJob(StorageDiskType::LOCAL, $short_paths_local);
94-
$delete_jobs[] = new FileDeleterJob(StorageDiskType::LOCAL, $short_path_watermarked_local);
95-
$delete_jobs[] = new FileDeleterJob(StorageDiskType::LOCAL, $live_photo_short_paths_local);
96-
$delete_jobs[] = new FileDeleterJob(StorageDiskType::S3, $short_paths_s3);
97-
$delete_jobs[] = new FileDeleterJob(StorageDiskType::S3, $short_path_watermarked_s3);
98-
$delete_jobs[] = new FileDeleterJob(StorageDiskType::S3, $live_photo_short_paths_s3);
99-
100-
// Now delete DB records
101-
// ! If we are deleting more than a few 1000 photos at once, we may run into
102-
// ! SQL query size limits. In that case, we need to chunk the deletion.
103-
104-
// Those we are keeping.
105-
DB::table('size_variants')->whereIn('id', $exclude_size_variants_ids)->update(['photo_id' => null]);
106-
// Those we delete
107-
DB::table('size_variants')->whereIn('photo_id', $photo_ids)->delete();
108-
DB::table('statistics')->whereIn('photo_id', $photo_ids)->delete();
109-
DB::table('palettes')->whereIn('photo_id', $photo_ids)->delete();
110-
DB::table('photo_album')->whereIn('photo_id', $photo_ids)->delete(); // Just to be sure.
111-
DB::table('photos')->whereIn('id', $photo_ids)->delete();
112-
113-
return $delete_jobs;
114-
}
115-
11650
/**
11751
* Deletes the designated photos from the DB.
11852
*
@@ -172,11 +106,14 @@ public function do(array $photo_ids, string|null $from_id): void
172106
// Finally, add the unsorted photos to be deleted.
173107
$delete_photo_ids = array_merge($delete_photo_ids, $unsorted_photo_ids);
174108

175-
// We delete the photos which are in the from and listed in albums.
176-
DB::table(PA::PHOTO_ALBUM)->whereIn(PA::PHOTO_ID, $photo_ids)->where(PA::ALBUM_ID, '=', $from_id)->delete();
177-
178109
$this->purchasable_service->deleteMulitplePhotoPurchasables($photo_ids, [$from_id]);
179-
$jobs = $this->forceDelete($delete_photo_ids);
110+
111+
$photos_to_be_deleted = new PhotosToBeDeletedDTO(
112+
force_delete_photo_ids: $delete_photo_ids,
113+
soft_delete_photo_ids: $photo_ids,
114+
album_ids: [$from_id],
115+
);
116+
$jobs = $photos_to_be_deleted->executeDelete();
180117

181118
// Dispatch events for affected albums to trigger recomputation
182119
PhotoDeleted::dispatch($from_id);
@@ -185,69 +122,4 @@ public function do(array $photo_ids, string|null $from_id): void
185122
dispatch($job);
186123
}
187124
}
188-
189-
/**
190-
* Collects all short paths of size variants which shall be deleted from
191-
* disk.
192-
*
193-
* Size variants which belong to a photo which has a duplicate that is
194-
* not going to be deleted are skipped.
195-
*
196-
* @param array<int,string> $photo_ids the photo IDs
197-
* @param StorageDiskType $storage_disk the storage disk to filter for (null = all)
198-
* @param array<int,string> $exclude_size_variants_ids size variant IDs to be excluded
199-
*
200-
* @return Collection<int,SizeVariant> the size variants to be deleted
201-
*
202-
* @throws QueryBuilderException
203-
*/
204-
private function collectSizeVariantPathsByPhotoID(array $photo_ids, StorageDiskType $storage_disk, array $exclude_size_variants_ids): Collection
205-
{
206-
if (count($photo_ids) === 0) {
207-
return collect([]);
208-
}
209-
210-
return SizeVariant::query()
211-
->from('size_variants as sv')
212-
->select(['sv.short_path', 'sv.short_path_watermarked'])
213-
->join('photos as p', 'p.id', '=', 'sv.photo_id')
214-
->whereIn('p.id', $photo_ids)
215-
->where('sv.storage_disk', '=', $storage_disk->value)
216-
->whereNotIn('sv.id', $exclude_size_variants_ids)
217-
->toBase()
218-
->get();
219-
}
220-
221-
/**
222-
* Collects all short paths of live photos which shall be deleted from
223-
* disk.
224-
*
225-
* Live photos which have a duplicate that is not going to be deleted are
226-
* skipped.
227-
*
228-
* @param array<int,string> $photo_ids the photo IDs
229-
* @param StorageDiskType $storage_disk the storage disk to filter for (null = all)
230-
*
231-
* @return Collection<int,object{live_photo_short_path:string}> the live photo short paths to be deleted
232-
*
233-
* @throws QueryBuilderException
234-
*/
235-
private function collectLivePhotoPathsByPhotoID(array $photo_ids, StorageDiskType $storage_disk): Collection
236-
{
237-
if (count($photo_ids) === 0) {
238-
return collect([]);
239-
}
240-
241-
return DB::table('photos', 'p')
242-
->select(['p.live_photo_short_path'])
243-
->join('size_variants as sv', function (JoinClause $join): void {
244-
$join
245-
->on('sv.photo_id', '=', 'p.id')
246-
->where('sv.type', '=', SizeVariantType::ORIGINAL);
247-
})
248-
->whereIn('p.id', $photo_ids)
249-
->whereNotNull('p.live_photo_short_path')
250-
->where('sv.storage_disk', '=', $storage_disk->value)
251-
->get();
252-
}
253125
}
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
namespace App\DTO\Delete;
1010

1111
use App\Actions\Shop\PurchasableService;
12+
use App\Exceptions\Internal\LycheeLogicException;
1213
use App\Models\Album;
1314
use Illuminate\Support\Collection;
1415
use Illuminate\Support\Facades\DB;
1516

16-
final class AlbumsToBeDeleteDTO
17+
final class AlbumsToBeDeletedDTO
1718
{
1819
/**
1920
* Container for all Albums and associated Tracks to be deleted.
@@ -41,6 +42,11 @@ public function __construct(
4142
public function executeDelete()
4243
{
4344
DB::transaction(function (): void {
45+
// Safety checks
46+
if (DB::table('photo_album')->whereIn('album_id', $this->album_ids)->count() > 0) {
47+
throw new LycheeLogicException('There are still photos linked to the albums to be deleted.');
48+
}
49+
4450
$purchasable_service = resolve(PurchasableService::class);
4551
$purchasable_service->deleteMultipleAlbumPurchasables($this->album_ids);
4652
DB::table('live_metrics')->whereIn('album_id', $this->album_ids)->delete();

0 commit comments

Comments
 (0)