diff --git a/app/Actions/Import/Pipes/ExecuteBatch.php b/app/Actions/Import/Pipes/ExecuteBatch.php index 005fdea5cef..d548ffd3326 100644 --- a/app/Actions/Import/Pipes/ExecuteBatch.php +++ b/app/Actions/Import/Pipes/ExecuteBatch.php @@ -11,6 +11,7 @@ use App\Contracts\Import\ImportPipe; use App\DTO\ImportDTO; use App\DTO\ImportEventReport; +use App\Jobs\ImportImageJob; class ExecuteBatch implements ImportPipe { @@ -37,11 +38,13 @@ public function handle(ImportDTO $state, \Closure $next): ImportDTO foreach ($state->job_bus as $idx => $job) { try { $progress = (int) (($idx + 1) * 100 / $total); - $this->report(ImportEventReport::createDebug('imported', $job->file_path, 'Processing... ' . $progress . '%')); + $path = ($job instanceof ImportImageJob) ? $job->file_path : get_class($job); + $this->report(ImportEventReport::createDebug('imported', $path, 'Processing... ' . $progress . '%')); dispatch($job); // @codeCoverageIgnoreStart } catch (\Throwable $e) { - $this->report(ImportEventReport::createFromException($e, $job->file_path)); + $path = ($job instanceof ImportImageJob) ? $job->file_path : get_class($job); + $this->report(ImportEventReport::createFromException($e, $path)); } // @codeCoverageIgnoreEnd } diff --git a/app/Actions/Import/Pipes/ImportPhotos.php b/app/Actions/Import/Pipes/ImportPhotos.php index 7d4989ef1e3..eb8906eccfa 100644 --- a/app/Actions/Import/Pipes/ImportPhotos.php +++ b/app/Actions/Import/Pipes/ImportPhotos.php @@ -15,6 +15,8 @@ use App\DTO\ImportEventReport; use App\Image\Files\NativeLocalFile; use App\Jobs\ImportImageJob; +use App\Jobs\RecomputeAlbumSizeJob; +use App\Jobs\RecomputeAlbumStatsJob; use App\Models\Album; use App\Models\Photo; use App\Repositories\ConfigManager; @@ -80,6 +82,12 @@ private function importImagesForNode(FolderNode $node): void foreach ($image_paths as $idx => $image_path) { $this->importSingleImage($image_path, $node->album, $idx / $total * 100); } + + // Dispatch recompute jobs for the album after importing photos + if ($node->album !== null) { + $this->state->job_bus[] = new RecomputeAlbumSizeJob($node->album?->id); + $this->state->job_bus[] = new RecomputeAlbumStatsJob($node->album?->id); + } } /** diff --git a/app/Actions/Photo/MoveOrDuplicate.php b/app/Actions/Photo/MoveOrDuplicate.php index 3acfbad615c..1e3b3f25965 100644 --- a/app/Actions/Photo/MoveOrDuplicate.php +++ b/app/Actions/Photo/MoveOrDuplicate.php @@ -12,8 +12,8 @@ use App\Actions\User\Notify; use App\Constants\PhotoAlbum as PA; use App\Contracts\Models\AbstractAlbum; +use App\Events\AlbumSaved; use App\Events\PhotoDeleted; -use App\Events\PhotoSaved; use App\Models\Album; use App\Models\Photo; use App\Models\Purchasable; @@ -50,6 +50,9 @@ public function do(Collection $photos, ?AbstractAlbum $from_album, ?Album $to_al ->whereIn(PA::PHOTO_ID, $photos_ids) ->where(PA::ALBUM_ID, '=', $from_album->get_id()) ->delete(); + + // Dispatch event for origin album (photos moved out) + AlbumSaved::dispatchIf($from_album instanceof Album, $from_album); } // Dispatch event for source album (photos removed) @@ -67,10 +70,7 @@ public function do(Collection $photos, ?AbstractAlbum $from_album, ?Album $to_al DB::table(PA::PHOTO_ALBUM)->insert(array_map(fn (string $id) => ['photo_id' => $id, 'album_id' => $to_album->id], $photos_ids)); // Dispatch event for destination album (photos added) - // Note: We dispatch PhotoSaved for each photo to trigger recomputation - foreach ($photos as $photo) { - PhotoSaved::dispatch($photo); - } + AlbumSaved::dispatchIf($to_album instanceof Album, $to_album); } // In case of move, we need to remove the header_id of said photos. diff --git a/app/Actions/Photo/Pipes/Shared/Save.php b/app/Actions/Photo/Pipes/Shared/Save.php index 7ccda5d5187..60ad890b486 100644 --- a/app/Actions/Photo/Pipes/Shared/Save.php +++ b/app/Actions/Photo/Pipes/Shared/Save.php @@ -23,7 +23,7 @@ public function handle(PhotoDTO $state, \Closure $next): PhotoDTO $state->getPhoto()->tags()->sync($state->getTags()->pluck('id')->all()); // Dispatch event for album stats recomputation - PhotoSaved::dispatch($state->getPhoto()); + PhotoSaved::dispatch($state->getPhoto()->id); return $next($state); } diff --git a/app/Actions/Statistics/Spaces.php b/app/Actions/Statistics/Spaces.php index 58e64bf39a1..2849a0ad922 100644 --- a/app/Actions/Statistics/Spaces.php +++ b/app/Actions/Statistics/Spaces.php @@ -24,6 +24,9 @@ final class Spaces /** * Return the amount of data stored on the server (optionally for a user). * + * Uses pre-computed album_size_statistics table for <100ms performance. + * Joins user's owned albums and sums their statistics. + * * @param int|null $owner_id * * @return Collection @@ -32,28 +35,24 @@ public function getFullSpacePerUser(?int $owner_id = null): Collection { return DB::table('users') ->when($owner_id !== null, fn ($query) => $query->where('users.id', '=', $owner_id)) - ->joinSub( - query: DB::table('photos')->select(['photos.id', 'photos.owner_id']), - as: 'photos', - first: 'photos.owner_id', - operator: '=', - second: 'users.id', - type: 'left' - ) - ->joinSub( - query: DB::table('size_variants') - ->select(['size_variants.photo_id', 'size_variants.filesize']) - ->where('size_variants.type', '!=', 7), - as: 'size_variants', - first: 'size_variants.photo_id', + ->leftJoinSub( + query: DB::table('base_albums')->select(['base_albums.id', 'base_albums.owner_id']), + as: 'base_albums', + first: 'base_albums.owner_id', operator: '=', - second: 'photos.id', - type: 'left' + second: 'users.id' ) + ->leftJoin('album_size_statistics', 'album_size_statistics.album_id', '=', 'base_albums.id') ->select( 'users.id', 'username', - DB::raw('SUM(size_variants.filesize) as size') + DB::raw('SUM(COALESCE(album_size_statistics.size_thumb, 0) + + COALESCE(album_size_statistics.size_thumb2x, 0) + + COALESCE(album_size_statistics.size_small, 0) + + COALESCE(album_size_statistics.size_small2x, 0) + + COALESCE(album_size_statistics.size_medium, 0) + + COALESCE(album_size_statistics.size_medium2x, 0) + + COALESCE(album_size_statistics.size_original, 0)) as size') ) ->groupBy('users.id', 'username') ->orderBy('users.id', 'asc') @@ -68,39 +67,85 @@ public function getFullSpacePerUser(?int $owner_id = null): Collection /** * Return the amount of data stored on the server (optionally for a user). * + * Uses pre-computed album_size_statistics for photos in albums, + * plus direct size_variants query for photos not in any album. + * * @param int|null $owner_id * * @return Collection */ public function getSpacePerSizeVariantTypePerUser(?int $owner_id = null): Collection { - return DB::table('size_variants') - ->when($owner_id !== null, fn ($query) => $query - ->joinSub( - query: DB::table('photos')->select(['photos.id', 'photos.owner_id']), - as: 'photos', - first: 'photos.id', - operator: '=', - second: 'size_variants.photo_id' - ) - ->where('photos.owner_id', '=', $owner_id)) + // Query 1: Get sizes from album_size_statistics for photos in albums + $album_stats = DB::table('base_albums') + ->when($owner_id !== null, fn ($query) => $query->where('base_albums.owner_id', '=', $owner_id)) + ->join('album_size_statistics', 'album_size_statistics.album_id', '=', 'base_albums.id') + ->select( + DB::raw('SUM(COALESCE(album_size_statistics.size_original, 0)) as size_original'), + DB::raw('SUM(COALESCE(album_size_statistics.size_medium2x, 0)) as size_medium2x'), + DB::raw('SUM(COALESCE(album_size_statistics.size_medium, 0)) as size_medium'), + DB::raw('SUM(COALESCE(album_size_statistics.size_small2x, 0)) as size_small2x'), + DB::raw('SUM(COALESCE(album_size_statistics.size_small, 0)) as size_small'), + DB::raw('SUM(COALESCE(album_size_statistics.size_thumb2x, 0)) as size_thumb2x'), + DB::raw('SUM(COALESCE(album_size_statistics.size_thumb, 0)) as size_thumb') + ) + ->first(); + + // Query 2: Get sizes from size_variants for photos NOT in any album + $unalbummed_photos_query = DB::table('size_variants') + ->joinSub( + query: DB::table('photos')->select(['photos.id', 'photos.owner_id']), + as: 'photos', + first: 'photos.id', + operator: '=', + second: 'size_variants.photo_id' + ) + ->whereNotExists(function ($query): void { + $query->select(DB::raw(1)) + ->from(PA::PHOTO_ALBUM) + ->whereColumn(PA::PHOTO_ID, '=', 'size_variants.photo_id'); + }) + ->when($owner_id !== null, fn ($query) => $query->where('photos.owner_id', '=', $owner_id)) + ->where('size_variants.type', '!=', 7) ->select( 'size_variants.type', DB::raw('SUM(size_variants.filesize) as size') ) - ->where('size_variants.type', '!=', 7) ->groupBy('size_variants.type') - ->orderBy('size_variants.type', 'asc') ->get() - ->map(fn ($item) => [ - 'type' => SizeVariantType::from($item->type), - 'size' => intval($item->size), - ]); + ->keyBy('type'); + + // Combine results by SizeVariantType + $combined = [ + SizeVariantType::ORIGINAL->value => intval($album_stats->size_original ?? 0), + SizeVariantType::MEDIUM2X->value => intval($album_stats->size_medium2x ?? 0), + SizeVariantType::MEDIUM->value => intval($album_stats->size_medium ?? 0), + SizeVariantType::SMALL2X->value => intval($album_stats->size_small2x ?? 0), + SizeVariantType::SMALL->value => intval($album_stats->size_small ?? 0), + SizeVariantType::THUMB2X->value => intval($album_stats->size_thumb2x ?? 0), + SizeVariantType::THUMB->value => intval($album_stats->size_thumb ?? 0), + ]; + + // Add unalbummed photo sizes + foreach ($unalbummed_photos_query as $type => $item) { + $combined[$type] = ($combined[$type] ?? 0) + intval($item->size); + } + + // Convert to collection and filter out zero sizes + return collect($combined) + ->filter(fn ($size) => $size > 0) + ->map(fn ($size, $type) => [ + 'type' => SizeVariantType::from($type), + 'size' => $size, + ]) + ->values(); } /** * Return the amount of data stored on the server (optionally for an album). * + * Uses pre-computed album_size_statistics table for performance. + * * @param string $album_id * * @return Collection @@ -117,33 +162,52 @@ public function getSpacePerSizeVariantTypePerAlbum(string $album_id): Collection ->on('albums._rgt', '>=', 'descendants._rgt'); } ) - ->join(PA::PHOTO_ALBUM, PA::ALBUM_ID, '=', 'descendants.id') - ->joinSub( - query: DB::table('size_variants') - ->select(['size_variants.id', 'size_variants.photo_id', 'size_variants.type', 'size_variants.filesize']) - ->where('size_variants.type', '!=', 7), - as: 'size_variants', - first: 'size_variants.photo_id', - operator: '=', - second: PA::PHOTO_ID, - ) + ->leftJoin('album_size_statistics', 'album_size_statistics.album_id', '=', 'descendants.id') ->select( - 'size_variants.type', - DB::raw('SUM(size_variants.filesize) as size') - ) - ->groupBy('size_variants.type') - ->orderBy('size_variants.type', 'asc'); + DB::raw('SUM(COALESCE(album_size_statistics.size_original, 0)) as size_original'), + DB::raw('SUM(COALESCE(album_size_statistics.size_medium2x, 0)) as size_medium2x'), + DB::raw('SUM(COALESCE(album_size_statistics.size_medium, 0)) as size_medium'), + DB::raw('SUM(COALESCE(album_size_statistics.size_small2x, 0)) as size_small2x'), + DB::raw('SUM(COALESCE(album_size_statistics.size_small, 0)) as size_small'), + DB::raw('SUM(COALESCE(album_size_statistics.size_thumb2x, 0)) as size_thumb2x'), + DB::raw('SUM(COALESCE(album_size_statistics.size_thumb, 0)) as size_thumb') + ); - return $query->get() - ->map(fn ($item) => [ - 'type' => SizeVariantType::from($item->type), - 'size' => intval($item->size), - ]); + $result = $query->first(); + + // Map aggregated sizes to SizeVariantType enum + $variants = []; + if (intval($result->size_original) > 0) { + $variants[] = ['type' => SizeVariantType::ORIGINAL, 'size' => intval($result->size_original)]; + } + if (intval($result->size_medium2x) > 0) { + $variants[] = ['type' => SizeVariantType::MEDIUM2X, 'size' => intval($result->size_medium2x)]; + } + if (intval($result->size_medium) > 0) { + $variants[] = ['type' => SizeVariantType::MEDIUM, 'size' => intval($result->size_medium)]; + } + if (intval($result->size_small2x) > 0) { + $variants[] = ['type' => SizeVariantType::SMALL2X, 'size' => intval($result->size_small2x)]; + } + if (intval($result->size_small) > 0) { + $variants[] = ['type' => SizeVariantType::SMALL, 'size' => intval($result->size_small)]; + } + if (intval($result->size_thumb2x) > 0) { + $variants[] = ['type' => SizeVariantType::THUMB2X, 'size' => intval($result->size_thumb2x)]; + } + if (intval($result->size_thumb) > 0) { + $variants[] = ['type' => SizeVariantType::THUMB, 'size' => intval($result->size_thumb)]; + } + + return collect($variants); } /** * Return size statistics per album. * + * Uses pre-computed album_size_statistics table for performance. + * Albums without statistics will report size as 0. + * * @param string|null $album_id * @param int|null $owner_id * @@ -172,22 +236,19 @@ public function getSpacePerAlbum(?string $album_id = null, ?int $owner_id = null second: 'albums.id' ) ->where('base_albums.owner_id', '=', $owner_id)) - ->join(PA::PHOTO_ALBUM, PA::ALBUM_ID, '=', 'albums.id') - ->joinSub( - query: DB::table('size_variants') - ->select(['size_variants.id', 'size_variants.photo_id', 'size_variants.filesize']) - ->where('size_variants.type', '!=', 7), - as: 'size_variants', - first: 'size_variants.photo_id', - operator: '=', - second: PA::PHOTO_ID - ) + ->leftJoin('album_size_statistics', 'album_size_statistics.album_id', '=', 'albums.id') ->select( 'albums.id', 'albums._lft', 'albums._rgt', - DB::raw('SUM(size_variants.filesize) as size'), - )->groupBy('albums.id') + DB::raw('(COALESCE(album_size_statistics.size_thumb, 0) + + COALESCE(album_size_statistics.size_thumb2x, 0) + + COALESCE(album_size_statistics.size_small, 0) + + COALESCE(album_size_statistics.size_small2x, 0) + + COALESCE(album_size_statistics.size_medium, 0) + + COALESCE(album_size_statistics.size_medium2x, 0) + + COALESCE(album_size_statistics.size_original, 0)) as size') + ) ->orderBy('albums._lft', 'asc'); return $query @@ -203,6 +264,9 @@ public function getSpacePerAlbum(?string $album_id = null, ?int $owner_id = null /** * Same as above but with full size (including sub-albums). * + * Uses pre-computed album_size_statistics table with nested set query + * to find descendants and sum their statistics. + * * @param string|null $album_id * @param int|null $owner_id * @@ -228,22 +292,19 @@ public function getTotalSpacePerAlbum(?string $album_id = null, ?int $owner_id = ->on('albums._rgt', '>=', 'descendants._rgt'); } ) - ->join(PA::PHOTO_ALBUM, PA::ALBUM_ID, '=', 'descendants.id') - ->joinSub( - query: DB::table('size_variants') - ->select(['size_variants.id', 'size_variants.photo_id', 'size_variants.filesize']) - ->where('size_variants.type', '!=', 7), - as: 'size_variants', - first: 'size_variants.photo_id', - operator: '=', - second: PA::PHOTO_ID - ) + ->leftJoin('album_size_statistics', 'album_size_statistics.album_id', '=', 'descendants.id') ->select( 'albums.id', 'albums._lft', 'albums._rgt', - DB::raw('SUM(size_variants.filesize) as size'), - )->groupBy('albums.id') + DB::raw('SUM(COALESCE(album_size_statistics.size_thumb, 0) + + COALESCE(album_size_statistics.size_thumb2x, 0) + + COALESCE(album_size_statistics.size_small, 0) + + COALESCE(album_size_statistics.size_small2x, 0) + + COALESCE(album_size_statistics.size_medium, 0) + + COALESCE(album_size_statistics.size_medium2x, 0) + + COALESCE(album_size_statistics.size_original, 0)) as size') + )->groupBy('albums.id', 'albums._lft', 'albums._rgt') ->orderBy('albums._lft', 'asc'); return $query @@ -259,6 +320,8 @@ public function getTotalSpacePerAlbum(?string $album_id = null, ?int $owner_id = /** * Return size statistics (number of photos rather than bytes) per album. * + * Uses the pre-computed num_photos column from the albums table for performance. + * * @param string|null $album_id * @param int|null $owner_id * @@ -287,7 +350,6 @@ public function getPhotoCountPerAlbum(?string $album_id = null, ?int $owner_id = second: 'albums.id' ) ->when($owner_id !== null, fn ($query) => $query->where('base_albums.owner_id', '=', $owner_id)) - ->join(PA::PHOTO_ALBUM, PA::ALBUM_ID, '=', 'albums.id') ->joinSub( query: DB::table('users')->select(['users.id', 'users.username']), as: 'users', @@ -302,14 +364,7 @@ public function getPhotoCountPerAlbum(?string $album_id = null, ?int $owner_id = 'base_albums.is_nsfw', 'albums._lft', 'albums._rgt', - DB::raw('COUNT(' . PA::PHOTO_ID . ') as num_photos'), - )->groupBy( - 'albums.id', - 'username', - 'base_albums.title', - 'base_albums.is_nsfw', - 'albums._lft', - 'albums._rgt', + 'albums.num_photos' ) ->orderBy('albums._lft', 'asc'); @@ -330,6 +385,9 @@ public function getPhotoCountPerAlbum(?string $album_id = null, ?int $owner_id = /** * Same as above but including sub-albums. * + * Uses the pre-computed num_photos column from the albums table, + * summed across all descendants for performance. + * * @param string|null $album_id * @param int|null $owner_id * @@ -348,14 +406,13 @@ public function getTotalPhotoCountPerAlbum(?string $album_id = null, ?int $owner ) ->when($owner_id !== null, fn ($query) => $query->where('base_albums.owner_id', '=', $owner_id)) ->joinSub( - query: DB::table('albums', 'descendants')->select('descendants.id', 'descendants._lft', 'descendants._rgt'), + query: DB::table('albums', 'descendants')->select('descendants.id', 'descendants._lft', 'descendants._rgt', 'descendants.num_photos'), as: 'descendants', first: function (JoinClause $join): void { $join->on('albums._lft', '<=', 'descendants._lft') ->on('albums._rgt', '>=', 'descendants._rgt'); } ) - ->join(PA::PHOTO_ALBUM, PA::ALBUM_ID, '=', 'descendants.id') ->joinSub( query: DB::table('users')->select(['users.id', 'users.username']), as: 'users', @@ -370,7 +427,7 @@ public function getTotalPhotoCountPerAlbum(?string $album_id = null, ?int $owner 'base_albums.is_nsfw', 'albums._lft', 'albums._rgt', - DB::raw('COUNT(' . PA::PHOTO_ID . ') as num_photos'), + DB::raw('SUM(descendants.num_photos) as num_photos') )->groupBy( 'albums.id', 'username', diff --git a/app/Console/Commands/RecomputeAlbumSizes.php b/app/Console/Commands/RecomputeAlbumSizes.php new file mode 100644 index 00000000000..3e1d9cbf04c --- /dev/null +++ b/app/Console/Commands/RecomputeAlbumSizes.php @@ -0,0 +1,168 @@ +argument('album_id'); + + // Dual behavior: with album_id = single-album mode, without = bulk mode + if ($album_id !== null) { + return $this->handleSingleAlbum($album_id); + } + + return $this->handleBulkBackfill(); + } + + /** + * Handle single-album recompute mode. + */ + private function handleSingleAlbum(string $album_id): int + { + $sync = $this->option('sync'); + + // Validate album exists + $album = Album::query()->find($album_id); + if ($album === null) { + $this->error("Album with ID '{$album_id}' not found"); + + return Command::FAILURE; + } + + $this->info("Recomputing sizes statistics for album: {$album->title} (ID: {$album_id})"); + + if ($sync) { + // Run synchronously + $this->info('Running synchronously...'); + try { + RecomputeAlbumSizeJob::dispatchSync($album_id); + + $this->info('✓ Sizes statistics recomputed successfully'); + Log::info("Manual recompute completed for album {$album_id}"); + + return Command::SUCCESS; + } catch (\Exception $e) { + $this->error('✗ Failed to recompute sizes statistics: ' . $e->getMessage()); + Log::error("Manual recompute failed for album {$album_id}: " . $e->getMessage()); + + return Command::FAILURE; + } + } + + // Dispatch job to queue + RecomputeAlbumSizeJob::dispatch($album_id, true); + + $this->info('✓ Job dispatched to queue'); + $this->info(' Note: Sizes statistics will be updated when the queue worker processes the job'); + Log::info("Manual recompute job dispatched for album {$album_id}"); + + return Command::SUCCESS; + } + + /** + * Handle bulk backfill mode for all albums. + */ + private function handleBulkBackfill(): int + { + $dry_run = $this->option('dry-run'); + $chunk_size = (int) $this->option('chunk'); + + if ($chunk_size < 1) { + $this->error('Chunk size must be at least 1'); + + return Command::FAILURE; + } + + $this->info('Starting album sizes backfill...'); + if ($dry_run) { + $this->warn('DRY RUN MODE - No changes will be made'); + } + + // Get total count + $total = Album::query()->count(); + $this->info("Found {$total} albums to process"); + + if ($total === 0) { + $this->info('No albums to process'); + + return Command::SUCCESS; + } + + // Process albums ordered by _lft ASC (leaf-to-root order) + // This ensures child albums are computed before parents + $bar = $this->output->createProgressBar($total); + $bar->start(); + + $processed = 0; + + Album::query() + ->orderBy('_lft', 'asc') + ->toBase() + ->chunk($chunk_size, function ($albums) use ($dry_run, &$processed, $bar): void { + foreach ($albums as $album) { + if (!$dry_run) { + // Dispatch job to recompute stats for this album + RecomputeAlbumSizeJob::dispatch($album->id, false); + } + + $processed++; + $bar->advance(); + + // Log progress at intervals + if ($processed % 100 === 0) { + $percentage = round(($processed / $bar->getMaxSteps()) * 100, 2); + Log::info("Backfilled {$processed}/{$bar->getMaxSteps()} albums ({$percentage}%)"); + } + } + }); + + $bar->finish(); + $this->newLine(2); + + if ($dry_run) { + $this->info("DRY RUN: Would have dispatched {$processed} jobs"); + } else { + $this->info("Dispatched {$processed} jobs to recompute album sizes statistics"); + $this->info('Jobs will be processed by the queue worker'); + $this->warn('Note: This operation may take some time for large galleries'); + } + + Log::info("Backfill completed: {$processed} albums processed"); + + return Command::SUCCESS; + } +} diff --git a/app/Console/Commands/RecomputeAlbumStats.php b/app/Console/Commands/RecomputeAlbumStats.php index 338223c190d..d3056209429 100644 --- a/app/Console/Commands/RecomputeAlbumStats.php +++ b/app/Console/Commands/RecomputeAlbumStats.php @@ -69,8 +69,7 @@ private function handleSingleAlbum(string $album_id): int // Run synchronously $this->info('Running synchronously...'); try { - $job = new RecomputeAlbumStatsJob($album_id); - $job->handle(); + RecomputeAlbumStatsJob::dispatchSync($album_id, true); $this->info('✓ Stats recomputed successfully'); Log::info("Manual recompute completed for album {$album_id}"); @@ -85,7 +84,7 @@ private function handleSingleAlbum(string $album_id): int } // Dispatch job to queue - RecomputeAlbumStatsJob::dispatch($album_id); + RecomputeAlbumStatsJob::dispatch($album_id, true); $this->info('✓ Job dispatched to queue'); $this->info(' Note: Stats will be updated when the queue worker processes the job'); @@ -137,7 +136,8 @@ private function handleBulkBackfill(): int foreach ($albums as $album) { if (!$dry_run) { // Dispatch job to recompute stats for this album - RecomputeAlbumStatsJob::dispatch($album->id); + // We do not propagate to parent here to avoid redundant jobs + RecomputeAlbumStatsJob::dispatch($album->id, false); } $processed++; diff --git a/app/DTO/ImportDTO.php b/app/DTO/ImportDTO.php index 40a1fec058b..8abb1026623 100644 --- a/app/DTO/ImportDTO.php +++ b/app/DTO/ImportDTO.php @@ -11,6 +11,8 @@ use App\Actions\Album\Create as AlbumCreate; use App\Actions\Photo\Create as PhotoCreate; use App\Jobs\ImportImageJob; +use App\Jobs\RecomputeAlbumSizeJob; +use App\Jobs\RecomputeAlbumStatsJob; use App\Metadata\Renamer\AlbumRenamer; use App\Metadata\Renamer\PhotoRenamer; use App\Models\Album; @@ -23,7 +25,7 @@ class ImportDTO protected AlbumRenamer $album_renamer; protected PhotoRenamer $photo_renamer; - /** @var ImportImageJob[] */ + /** @var (ImportImageJob|RecomputeAlbumSizeJob|RecomputeAlbumStatsJob)[] */ public array $job_bus = []; public function __construct( diff --git a/app/Events/PhotoSaved.php b/app/Events/PhotoSaved.php index af0d204ec7c..75484ccf8a0 100644 --- a/app/Events/PhotoSaved.php +++ b/app/Events/PhotoSaved.php @@ -8,7 +8,6 @@ namespace App\Events; -use App\Models\Photo; use Illuminate\Foundation\Events\Dispatchable; use Illuminate\Queue\SerializesModels; @@ -20,7 +19,7 @@ class PhotoSaved /** * Create a new event instance. */ - public function __construct(public Photo $photo) + public function __construct(public string $photo_id) { } } diff --git a/app/Http/Controllers/Admin/Maintenance/BackfillAlbumSizes.php b/app/Http/Controllers/Admin/Maintenance/BackfillAlbumSizes.php new file mode 100644 index 00000000000..7cea25a3a65 --- /dev/null +++ b/app/Http/Controllers/Admin/Maintenance/BackfillAlbumSizes.php @@ -0,0 +1,95 @@ +when($is_sync, fn ($q) => $q->whereDoesntHave('sizeStatistics')) + ->orderBy('_lft', 'asc'); // Leaf-to-root order for proper propagation + + if ($is_sync) { + // For sync queue, process in chunks by _lft ASC (leaf to root) + // This allows parent propagation to work correctly + $albums = $query->limit(50)->toBase()->get(['id']); + $albums->each(function ($album): void { + RecomputeAlbumSizeJob::dispatch($album->id, false); + }); + } else { + // For async queue, dispatch all jobs at once + // The queue worker will handle them + $query->toBase() + ->select(['id']) + ->lazy(500) + ->each(function ($album): void { + RecomputeAlbumSizeJob::dispatch($album->id, false); + }); + } + } + + /** + * Count albums that need size statistics filled. + * + * Returns the count of albums that don't have a corresponding + * row in the album_size_statistics table. + * + * @param MaintenanceRequest $request Authenticated maintenance request (admin only) + * + * @return int Total number of albums needing size statistics + */ + public function check(MaintenanceRequest $request): int + { + if (Config::get('queue.default', 'sync') !== 'sync') { + return -1; + } + + return Album::query() + ->whereDoesntHave('sizeStatistics') + ->count(); + } +} diff --git a/app/Http/Controllers/Admin/Maintenance/FulfillPreCompute.php b/app/Http/Controllers/Admin/Maintenance/FulfillPreCompute.php index 04723f4f272..3ce44d1ac2f 100644 --- a/app/Http/Controllers/Admin/Maintenance/FulfillPreCompute.php +++ b/app/Http/Controllers/Admin/Maintenance/FulfillPreCompute.php @@ -58,18 +58,20 @@ public function do(MaintenanceRequest $request): void if ($is_sync) { // For sync queue, process in chunks by _lft DESC (leaf to root) // This reduces re-computation as parents are processed before children - $albums = $query->limit(50)->toBase()->get(['id']); + // We do not propagate to parent here to avoid redundant jobs + $albums = $query->limit(100)->toBase()->get(['id']); $albums->each(function ($album): void { - RecomputeAlbumStatsJob::dispatch($album->id); + RecomputeAlbumStatsJob::dispatch($album->id, false); }); } else { // For async queue, dispatch all jobs at once + // We do not propagate to parent here to avoid redundant jobs // The queue worker will handle them $query->toBase() ->select(['id']) ->lazy(500) ->each(function ($album): void { - RecomputeAlbumStatsJob::dispatch($album->id); + RecomputeAlbumStatsJob::dispatch($album->id, false); }); } } diff --git a/app/Http/Controllers/Admin/Maintenance/GenSizeVariants.php b/app/Http/Controllers/Admin/Maintenance/GenSizeVariants.php index 82390055a77..e658cb13165 100644 --- a/app/Http/Controllers/Admin/Maintenance/GenSizeVariants.php +++ b/app/Http/Controllers/Admin/Maintenance/GenSizeVariants.php @@ -11,6 +11,7 @@ use App\Contracts\Models\SizeVariantFactory; use App\Enum\SizeVariantType; use App\Events\AlbumRouteCacheUpdated; +use App\Events\PhotoSaved; use App\Exceptions\MediaFileOperationException; use App\Http\Requests\Maintenance\CreateThumbsRequest; use App\Image\PlaceholderEncoder; @@ -62,6 +63,8 @@ public function do(CreateThumbsRequest $request, SizeVariantFactory $size_varian } catch (MediaFileOperationException $e) { Log::error('Failed to create ' . $request->kind()->value . ' for photo id ' . $photo->id . ''); } + // recompute the sizes. + PhotoSaved::dispatch($photo->id); AlbumRouteCacheUpdated::dispatch(); // @codeCoverageIgnoreEnd diff --git a/app/Http/Controllers/Admin/Maintenance/MissingFileSizes.php b/app/Http/Controllers/Admin/Maintenance/MissingFileSizes.php index 8db1290a01a..af0a350fdae 100644 --- a/app/Http/Controllers/Admin/Maintenance/MissingFileSizes.php +++ b/app/Http/Controllers/Admin/Maintenance/MissingFileSizes.php @@ -10,6 +10,7 @@ use App\Enum\StorageDiskType; use App\Events\AlbumRouteCacheUpdated; +use App\Events\PhotoSaved; use App\Http\Requests\Maintenance\MaintenanceRequest; use App\Models\SizeVariant; use Illuminate\Routing\Controller; @@ -50,6 +51,7 @@ public function do(MaintenanceRequest $request): void Log::error('Failed to update filesize for ' . $variant_file->getRelativePath() . '.'); } else { $generated++; + PhotoSaved::dispatch($variant->photo_id); } } catch (UnableToRetrieveMetadata) { Log::error($variant->id . ' : Failed to get filesize for ' . $variant_file->getRelativePath() . '.'); diff --git a/app/Jobs/RecomputeAlbumSizeJob.php b/app/Jobs/RecomputeAlbumSizeJob.php new file mode 100644 index 00000000000..9065eb07d74 --- /dev/null +++ b/app/Jobs/RecomputeAlbumSizeJob.php @@ -0,0 +1,194 @@ +jobId = uniqid('job_', true); + + // Register this as the latest job for this album + Cache::put( + 'album_size_latest_job:' . $this->album_id, + $this->jobId, + ttl: now()->plus(days: 1) + ); + } + + /** + * Get the middleware the job should pass through. + * + * @return array + */ + public function middleware(): array + { + return [ + Skip::when(fn () => $this->hasNewerJobQueued()), + ]; + } + + protected function hasNewerJobQueued(): bool + { + $cache_key = 'album_size_latest_job:' . $this->album_id; + $latest_job_id = Cache::get($cache_key); + + // We skip if there is a newer job queued (latest job ID is different from this one) + $has_newer_job = $latest_job_id !== null && $latest_job_id !== $this->jobId; + if ($has_newer_job) { + Log::info("Skipping job {$this->jobId} for album {$this->album_id} due to newer job {$latest_job_id} queued."); + } + + return $has_newer_job; + } + + /** + * Execute the job. + * + * @return void + */ + public function handle(): void + { + Log::info("Recomputing sizes for album {$this->album_id} (job {$this->jobId})"); + Cache::forget("album_size_latest_job:{$this->album_id}"); + + try { + // Fetch the album + $album = Album::where('id', '=', $this->album_id)->first(); + if ($album === null) { + Log::warning("Album {$this->album_id} not found, skipping recompute."); + + return; + } + + // Compute sizes by querying size_variants for photos in this album (direct children only) + // Exclude PLACEHOLDER (type 7) from all size calculations + $sizes = $this->computeSizes($album); + + // Update or create statistics row + AlbumSizeStatistics::updateOrCreate( + ['album_id' => $album->id], + $sizes + ); + + Log::debug("Updated size statistics for album {$album->id}"); + + // Propagate to parent if exists + if ($album->parent_id !== null && $this->propagate_to_parent) { + Log::debug("Propagating to parent {$album->parent_id}"); + self::dispatch($album->parent_id); + } + } catch (\Exception $e) { + Log::error("Propagation stopped at album {$this->album_id} due to failure: " . $e->getMessage()); + + throw $e; + } + } + + /** + * Compute size breakdown for all variant types. + * + * Queries size_variants for photos directly in this album (NOT descendants), + * groups by variant type, sums filesize. Excludes PLACEHOLDER (type 7). + * + * @param Album $album + * + * @return array Array with keys: size_thumb, size_thumb2x, size_small, size_small2x, size_medium, size_medium2x, size_original + */ + private function computeSizes(Album $album): array + { + // Initialize all sizes to 0 + $sizes = [ + 'size_thumb' => 0, + 'size_thumb2x' => 0, + 'size_small' => 0, + 'size_small2x' => 0, + 'size_medium' => 0, + 'size_medium2x' => 0, + 'size_original' => 0, + ]; + + // Query size_variants for photos in this album + // JOIN: size_variants -> photos -> photo_album + // Filter by album_id, exclude PLACEHOLDER (type 7) + // Group by type, SUM filesize + $results = DB::table('size_variants') + ->join('photos', 'size_variants.photo_id', '=', 'photos.id') + ->join(PA::PHOTO_ALBUM, 'photos.id', '=', PA::PHOTO_ID) + ->where(PA::ALBUM_ID, '=', $album->id) + ->where('size_variants.type', '!=', SizeVariantType::PLACEHOLDER->value) + ->select('size_variants.type', DB::raw('SUM(size_variants.filesize) as total_size')) + ->groupBy('size_variants.type') + ->get(); + + // Map results to size array + foreach ($results as $row) { + $type = SizeVariantType::from($row->type); + $column_name = 'size_' . $type->name(); + if (isset($sizes[$column_name])) { + $sizes[$column_name] = (int) $row->total_size; + } + } + + return $sizes; + } + + /** + * Handle job failure after all retries exhausted. + * + * @param \Throwable $exception + * + * @return void + */ + public function failed(\Throwable $exception): void + { + Log::error("Job failed permanently for album {$this->album_id}: " . $exception->getMessage()); + // Do NOT dispatch parent job on failure - propagation stops here + } +} diff --git a/app/Jobs/RecomputeAlbumStatsJob.php b/app/Jobs/RecomputeAlbumStatsJob.php index 335247faef5..0d3cb4ae0a2 100644 --- a/app/Jobs/RecomputeAlbumStatsJob.php +++ b/app/Jobs/RecomputeAlbumStatsJob.php @@ -55,6 +55,7 @@ class RecomputeAlbumStatsJob implements ShouldQueue */ public function __construct( public string $album_id, + public bool $propagate_to_parent = true, ) { $this->jobId = uniqid('job_', true); @@ -142,7 +143,7 @@ public function handle(): void $album->save(); // Propagate to parent if exists - if ($album->parent_id !== null) { + if ($album->parent_id !== null && $this->propagate_to_parent) { Log::debug("Propagating to parent {$album->parent_id}"); self::dispatch($album->parent_id); } diff --git a/app/Listeners/RecomputeAlbumSizeOnAlbumChange.php b/app/Listeners/RecomputeAlbumSizeOnAlbumChange.php new file mode 100644 index 00000000000..1bd0aecb47b --- /dev/null +++ b/app/Listeners/RecomputeAlbumSizeOnAlbumChange.php @@ -0,0 +1,60 @@ +album->id} saved, dispatching recompute job"); + RecomputeAlbumSizeJob::dispatch($event->album->id); + } + + /** + * Handle AlbumDeleted event. + * + * @param AlbumDeleted $event + * + * @return void + */ + public function handleAlbumDeleted(AlbumDeleted $event): void + { + // Dispatch job only if album had a parent + RecomputeAlbumSizeJob::dispatchIf( + $event->parent_id !== null, + $event->parent_id + ); + + if ($event->parent_id !== null) { + Log::info("Album deleted from parent {$event->parent_id}, dispatching recompute job"); + } + } +} diff --git a/app/Listeners/RecomputeAlbumSizeOnPhotoMutation.php b/app/Listeners/RecomputeAlbumSizeOnPhotoMutation.php new file mode 100644 index 00000000000..86514d45cd0 --- /dev/null +++ b/app/Listeners/RecomputeAlbumSizeOnPhotoMutation.php @@ -0,0 +1,71 @@ +photo_id); + + // Get all albums this photo belongs to (many-to-many relationship) + $album_ids = $photo->albums()->pluck('id'); + + foreach ($album_ids as $album_id) { + Log::debug("Photo {$event->photo_id} saved, dispatching size recompute for album {$album_id}"); + RecomputeAlbumSizeJob::dispatch($album_id); + } + } + + /** + * Handle PhotoDeleted event. + * + * When a photo is deleted, the event contains the album_id that the photo belonged to. + * Dispatch a recomputation job for that album. + * + * @param PhotoDeleted $event + * + * @return void + */ + public function handlePhotoDeleted(PhotoDeleted $event): void + { + $album_id = $event->album_id; + + Log::debug("Photo deleted from album {$album_id}, dispatching size recompute"); + RecomputeAlbumSizeJob::dispatch($album_id); + } +} diff --git a/app/Listeners/RecomputeAlbumStatsOnPhotoChange.php b/app/Listeners/RecomputeAlbumStatsOnPhotoChange.php index a103dbd86af..e100aaaad37 100644 --- a/app/Listeners/RecomputeAlbumStatsOnPhotoChange.php +++ b/app/Listeners/RecomputeAlbumStatsOnPhotoChange.php @@ -28,7 +28,7 @@ public function handlePhotoSaved(PhotoSaved $event): void { // Get all albums this photo belongs to $album_ids = DB::table(PA::PHOTO_ALBUM) - ->where('photo_id', '=', $event->photo->id) + ->where('photo_id', '=', $event->photo_id) ->pluck('album_id') ->all(); @@ -37,7 +37,7 @@ public function handlePhotoSaved(PhotoSaved $event): void return; } - Log::info("Photo {$event->photo->id} saved, dispatching recompute jobs for " . count($album_ids) . ' album(s)'); + Log::info("Photo {$event->photo_id} saved, dispatching recompute jobs for " . count($album_ids) . ' album(s)'); foreach ($album_ids as $album_id) { RecomputeAlbumStatsJob::dispatch($album_id); } diff --git a/app/Metadata/Cache/RouteCacheManager.php b/app/Metadata/Cache/RouteCacheManager.php index 8e7f4dab305..f95a03909bc 100644 --- a/app/Metadata/Cache/RouteCacheManager.php +++ b/app/Metadata/Cache/RouteCacheManager.php @@ -87,7 +87,7 @@ public function __construct() 'api/v2/Maintenance::fulfillOrders' => false, 'api/v2/Maintenance::fulfillPrecompute' => false, 'api/v2/Maintenance::flushQueue' => false, - + 'api/v2/Maintenance::backfillAlbumSizes' => false, 'api/v2/Map' => new RouteCacheConfig(tag: CacheTag::GALLERY, user_dependant: true, extra: [RequestAttribute::ALBUM_ID_ATTRIBUTE]), 'api/v2/Map::provider' => new RouteCacheConfig(tag: CacheTag::SETTINGS), 'api/v2/Oauth' => new RouteCacheConfig(tag: CacheTag::USER, user_dependant: true), diff --git a/app/Models/Album.php b/app/Models/Album.php index fb372785db1..b33c890604c 100644 --- a/app/Models/Album.php +++ b/app/Models/Album.php @@ -62,6 +62,7 @@ * @property Photo|null $cover * @property string|null $header_id * @property Photo|null $header + * @property AlbumSizeStatistics|null $sizeStatistics Pre-computed size statistics for this album. * @property string|null $track_short_path * @property string|null $track_url * @property AspectRatioType|null $album_thumb_aspect_ratio @@ -302,6 +303,16 @@ public function header(): HasOne return $this->hasOne(Photo::class, 'id', 'header_id'); } + /** + * Return the relationship between an album and its size statistics. + * + * @return HasOne + */ + public function sizeStatistics(): HasOne + { + return $this->hasOne(AlbumSizeStatistics::class, 'album_id', 'id'); + } + /** * Return the License used by the album. * diff --git a/app/Models/AlbumSizeStatistics.php b/app/Models/AlbumSizeStatistics.php new file mode 100644 index 00000000000..425a10e1638 --- /dev/null +++ b/app/Models/AlbumSizeStatistics.php @@ -0,0 +1,119 @@ + + */ + protected $fillable = [ + 'album_id', + 'size_thumb', + 'size_thumb2x', + 'size_small', + 'size_small2x', + 'size_medium', + 'size_medium2x', + 'size_original', + ]; + + /** + * The attributes that should be cast. + * + * @var array + */ + protected $casts = [ + 'size_thumb' => 'integer', + 'size_thumb2x' => 'integer', + 'size_small' => 'integer', + 'size_small2x' => 'integer', + 'size_medium' => 'integer', + 'size_medium2x' => 'integer', + 'size_original' => 'integer', + ]; + + /** + * Get the album that owns these statistics. + * + * @return BelongsTo + */ + public function album(): BelongsTo + { + return $this->belongsTo(Album::class, 'album_id', 'id'); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php index f3a817f8f6d..03e9f972519 100644 --- a/app/Providers/EventServiceProvider.php +++ b/app/Providers/EventServiceProvider.php @@ -26,6 +26,8 @@ use App\Listeners\CacheListener; use App\Listeners\MetricsListener; use App\Listeners\OrderCompletedListener; +use App\Listeners\RecomputeAlbumSizeOnAlbumChange; +use App\Listeners\RecomputeAlbumSizeOnPhotoMutation; use App\Listeners\RecomputeAlbumStatsOnAlbumChange; use App\Listeners\RecomputeAlbumStatsOnPhotoChange; use App\Listeners\TaggedRouteCacheCleaner; @@ -103,5 +105,10 @@ public function boot(): void Event::listen(PhotoDeleted::class, RecomputeAlbumStatsOnPhotoChange::class . '@handlePhotoDeleted'); Event::listen(AlbumSaved::class, RecomputeAlbumStatsOnAlbumChange::class . '@handleAlbumSaved'); Event::listen(AlbumDeleted::class, RecomputeAlbumStatsOnAlbumChange::class . '@handleAlbumDeleted'); + + Event::listen(PhotoSaved::class, RecomputeAlbumSizeOnPhotoMutation::class . '@handlePhotoSaved'); + Event::listen(PhotoDeleted::class, RecomputeAlbumSizeOnPhotoMutation::class . '@handlePhotoDeleted'); + Event::listen(AlbumSaved::class, RecomputeAlbumSizeOnAlbumChange::class . '@handleAlbumSaved'); + Event::listen(AlbumDeleted::class, RecomputeAlbumSizeOnAlbumChange::class . '@handleAlbumDeleted'); } } diff --git a/database/factories/SizeVariantFactory.php b/database/factories/SizeVariantFactory.php index 456656b20f7..4a32e994cfa 100644 --- a/database/factories/SizeVariantFactory.php +++ b/database/factories/SizeVariantFactory.php @@ -60,4 +60,67 @@ public function allSizeVariants(): Factory ['type' => SizeVariantType::THUMB, 'short_path' => SizeVariantType::THUMB->name() . '/' . $url, 'ratio' => 1.5, 'height' => 200, 'width' => 200, 'filesize' => 40_000, 'storage_disk' => 'images'], )); } + + /** + * Set the photo for this size variant. + * + * @param \App\Models\Photo $photo + * + * @return self + */ + public function for_photo($photo): self + { + return $this->state([ + 'photo_id' => $photo->id, + ]); + } + + /** + * Set the variant type. + * + * @param SizeVariantType $type + * + * @return self + */ + public function type(SizeVariantType $type): self + { + $hash = fake()->sha1(); + $url = substr($hash, 0, 2) . '/' . substr($hash, 2, 2) . '/' . substr($hash, 4) . '.jpg'; + + // Set appropriate dimensions based on type + $dimensions = match ($type) { + SizeVariantType::ORIGINAL => ['width' => self::W * 8, 'height' => self::H * 8, 'filesize' => 64 * self::FS], + SizeVariantType::MEDIUM2X => ['width' => self::W * 6, 'height' => self::H * 6, 'filesize' => 36 * self::FS], + SizeVariantType::MEDIUM => ['width' => self::W * 3, 'height' => self::H * 3, 'filesize' => 9 * self::FS], + SizeVariantType::SMALL2X => ['width' => self::W * 2, 'height' => self::H * 2, 'filesize' => 4 * self::FS], + SizeVariantType::SMALL => ['width' => self::W, 'height' => self::H, 'filesize' => self::FS], + SizeVariantType::THUMB2X => ['width' => 400, 'height' => 400, 'filesize' => 160_000], + SizeVariantType::THUMB => ['width' => 200, 'height' => 200, 'filesize' => 40_000], + SizeVariantType::PLACEHOLDER => ['width' => 32, 'height' => 32, 'filesize' => 1000], + }; + + return $this->state([ + 'type' => $type, + 'short_path' => $type->name() . '/' . $url, + 'width' => $dimensions['width'], + 'height' => $dimensions['height'], + 'filesize' => $dimensions['filesize'], + 'ratio' => 1.5, + 'storage_disk' => 'images', + ]); + } + + /** + * Set the filesize. + * + * @param int $size + * + * @return self + */ + public function with_size(int $size): self + { + return $this->state([ + 'filesize' => $size, + ]); + } } diff --git a/database/migrations/2026_01_02_203124_create_album_size_statistics_table.php b/database/migrations/2026_01_02_203124_create_album_size_statistics_table.php new file mode 100644 index 00000000000..f7cb742dc21 --- /dev/null +++ b/database/migrations/2026_01_02_203124_create_album_size_statistics_table.php @@ -0,0 +1,44 @@ +char('album_id', 24)->primary(); + $table->foreign('album_id')->references('id')->on('albums')->onDelete('cascade'); + + // Size columns for each variant type (bigint unsigned for large filesizes) + $table->unsignedBigInteger('size_thumb')->default(0); + $table->unsignedBigInteger('size_thumb2x')->default(0); + $table->unsignedBigInteger('size_small')->default(0); + $table->unsignedBigInteger('size_small2x')->default(0); + $table->unsignedBigInteger('size_medium')->default(0); + $table->unsignedBigInteger('size_medium2x')->default(0); + $table->unsignedBigInteger('size_original')->default(0); + + // No timestamps - this is a computed statistics table + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('album_size_statistics'); + } +}; diff --git a/docs/specs/4-architecture/features/004-album-size-statistics/plan.md b/docs/specs/4-architecture/features/004-album-size-statistics/plan.md new file mode 100644 index 00000000000..f0078c08be3 --- /dev/null +++ b/docs/specs/4-architecture/features/004-album-size-statistics/plan.md @@ -0,0 +1,432 @@ +# Feature Plan 004 – Album Size Statistics Pre-computation + +_Linked specification:_ `docs/specs/4-architecture/features/004-album-size-statistics/spec.md` +_Status:_ Draft +_Last updated:_ 2026-01-02 + +> Guardrail: Keep this plan traceable back to the governing spec. Reference FR/NFR/Scenario IDs from `spec.md` where relevant, log any new high- or medium-impact questions in [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md), and assume clarifications are resolved only when the spec's normative sections (requirements/NFR/behaviour/telemetry) and, where applicable, ADRs under `docs/specs/6-decisions/` have been updated. + +## Vision & Success Criteria + +**User Value:** Dramatically reduce load times for storage statistics pages and user quota displays. Currently, fetching storage data for users with large galleries can take 5-10 seconds due to expensive aggregate queries across `size_variants`, `photo_album`, and nested album trees. After implementation, these queries should complete in <100ms (80%+ reduction). + +**Success Signals:** +- `getFullSpacePerUser()` query time reduced from ~5s to <100ms for users with 10k photos +- `getTotalSpacePerAlbum()` query time reduced by 80%+ for albums with 1000+ photos +- User storage statistics page loads instantly +- Album size statistics update within 30 seconds of photo upload/deletion (eventual consistency) + +**Quality Bars:** +- All computed sizes match current `Spaces.php` runtime calculations (±1 byte tolerance) +- Backfill completes for 100k albums in <10 minutes +- Zero data loss: FK cascades ensure orphaned statistics cleaned up automatically +- Job queue remains stable: Skip middleware prevents wasted CPU from concurrent updates + +## Scope Alignment + +**In scope:** +- New `album_size_statistics` table with per-variant size columns +- `RecomputeAlbumSizeJob` with Skip middleware (pattern from Feature 003) +- Event listeners for photo/album/variant mutations triggering recomputation +- Propagation logic: recompute parent albums recursively up to root +- Refactor `Spaces.php` methods to read from table instead of runtime aggregation +- Artisan command `lychee:backfill-album-sizes` for migration +- Artisan command `lychee:recompute-album-sizes {album_id}` for manual recovery +- Maintenance UI button to trigger backfill (similar to "Generate Size Variants") +- Fallback logic: if statistics row missing, use runtime calculation (defensive) + +**Out of scope:** +- Storage quota enforcement (separate feature) +- Historical size tracking over time (only current state) +- Per-photo size caching (only per-album aggregates) +- Real-time updates (eventual consistency acceptable) +- Modifying Feature 003's `RecomputeAlbumStatsJob` (separate jobs) + +## Dependencies & Interfaces + +**Modules:** +- `app/Models/Album.php` - nested set tree structure (`_lft`, `_rgt`, `parent_id`) +- `app/Models/SizeVariant.php` - photo variant filesizes +- `app/Models/Photo.php` - photos table +- `app/Actions/Statistics/Spaces.php` - current runtime calculation logic (reference implementation) +- `app/Jobs/RecomputeAlbumStatsJob.php` - Skip middleware pattern to reuse +- `app/Constants/PhotoAlbum.php` - `photo_album` pivot table constants +- `app/Enum/SizeVariantType.php` - variant type enum (0-7, exclude PLACEHOLDER=7) + +**External dependencies:** +- Laravel queue system (Feature 002 Worker Mode recommended but not required) +- Cache system (for Skip middleware job deduplication) +- Database supports `bigint unsigned` (18 exabyte max filesize) + +**Telemetry contracts:** +- Log migration execution (TE-004-01) +- Log job dispatch, skip, propagation (TE-004-02) +- Log backfill progress (TE-004-03) + +## Assumptions & Risks + +**Assumptions:** +- Albums table has `id`, `_lft`, `_rgt`, `parent_id` columns (nested set model) +- SizeVariants table has `photo_id`, `type`, `filesize` columns +- Photo-album relationship via `photo_album` pivot table +- PLACEHOLDER variants (type 7) should be excluded from size calculations (verified in Spaces.php:46) +- Migration can run without downtime (schema change only, no long-running backfill) + +**Risks / Mitigations:** +- **Risk:** Backfill takes too long for large installations (100k+ albums) + - **Mitigation:** Chunk processing (1000 albums per batch), progress bar, idempotent (can resume) +- **Risk:** Job queue overwhelmed during backfill + - **Mitigation:** Backfill runs outside normal traffic hours (operator-controlled timing) +- **Risk:** Statistics drift out of sync if events missed + - **Mitigation:** Manual `lychee:recompute-album-sizes` command for recovery, fallback to runtime calculation +- **Risk:** Database contention during propagation (many parent updates) + - **Mitigation:** Skip middleware deduplicates concurrent jobs, transactions isolate updates + +## Implementation Drift Gate + +**Evidence collection:** +- Before/after query performance benchmarks for `Spaces.php` methods (record in `docs/specs/4-architecture/features/004-album-size-statistics/performance-benchmarks.md`) +- Sample size verification: compare 1000 random albums' computed sizes against runtime calculation (tolerance ±1 byte) +- Test coverage report: ensure all scenarios S-004-01 through S-004-13 have passing tests + +**Rerun commands:** +- `php artisan test --filter AlbumSizeStatisticsTest` +- `vendor/bin/php-cs-fixer fix app/Jobs/RecomputeAlbumSizeJob.php app/Actions/Statistics/Spaces.php` +- `make phpstan` + +**Drift gate checklist:** +- [ ] Spec FR-004-01 through FR-004-05 requirements implemented +- [ ] NFR-004-01 through NFR-004-05 performance targets met +- [ ] All 13 scenarios pass integration tests +- [ ] No regressions in existing Spaces.php tests +- [ ] Backfill tested on staging clone (100k+ albums) + +## Increment Map + +### **I1 – Database Migration: Create album_size_statistics Table** +- **Goal:** Add new table with schema per FR-004-01, ensure reversibility +- **Preconditions:** None (foundational increment) +- **Steps:** + 1. Write migration `YYYY_MM_DD_HHMMSS_create_album_size_statistics_table.php` + 2. Schema: `album_id` (string PK, FK albums.id ON DELETE CASCADE), 7 size columns (bigint unsigned, default 0) + 3. Add indexes: primary key on `album_id` + 4. Write `down()` method: drop table + 5. Test: run migration up/down, verify table created/dropped + 6. Test: create album with statistics, delete album, verify FK cascade deletes statistics +- **Commands:** + - `php artisan make:migration create_album_size_statistics_table` + - `php artisan migrate` + - `php artisan migrate:rollback` +- **Exit:** Migration runs cleanly in both directions, FK constraint verified + +### **I2 – Eloquent Model: AlbumSizeStatistics** +- **Goal:** Create model for `album_size_statistics` table +- **Preconditions:** I1 complete +- **Steps:** + 1. Create `app/Models/AlbumSizeStatistics.php` model + 2. Set `$table = 'album_size_statistics'`, `$primaryKey = 'album_id'`, `public $incrementing = false` + 3. Define `$fillable`: all 7 size columns + 4. Define `$casts`: all size columns as `int` + 5. Define relationship: `belongsTo(Album::class, 'album_id')` + 6. Add inverse relationship to `Album.php`: `hasOne(AlbumSizeStatistics::class, 'album_id')` + 7. Write unit test: create statistics record, assert relationships work +- **Commands:** + - `php artisan test --filter AlbumSizeStatisticsModelTest` +- **Exit:** Model created, relationships functional, unit test passes + +### **I3 – RecomputeAlbumSizeJob: Core Job Logic** +- **Goal:** Implement job that computes size statistics for single album (no propagation yet), per FR-004-02 +- **Preconditions:** I1, I2 complete +- **Steps:** + 1. Create `app/Jobs/RecomputeAlbumSizeJob.php` implementing `ShouldQueue` + 2. Add traits: `Dispatchable`, `InteractsWithQueue`, `Queueable`, `SerializesModels` + 3. Constructor: accept `string $album_id`, generate unique `$jobId`, store in cache per Q-004-03 resolution + 4. Implement `middleware()`: return `[Skip::when(fn() => $this->hasNewerJobQueued())]` + 5. Implement `hasNewerJobQueued()`: check cache key `album_size_latest_job:{album_id}`, log skip + 6. Implement `handle()`: + - Clear cache key + - Fetch album or return if not found + - Query `size_variants` for photos in album (JOIN `photo_album`, WHERE `album_id`, exclude type 7) + - GROUP BY `type`, SUM(`filesize`) + - Build size array: initialize all 7 sizes to 0, populate from query results + - `AlbumSizeStatistics::updateOrCreate(['album_id' => $album->id], $size_array)` + - Wrap in DB transaction + 7. Set `public $tries = 3` + 8. Implement `failed(\Throwable $exception)`: log permanent failure + 9. Write unit test: mock album with photos, run job, assert statistics computed correctly + 10. Test PLACEHOLDER exclusion: add type 7 variant, verify not included in sizes +- **Commands:** + - `php artisan test --filter RecomputeAlbumSizeJobTest` +- **Exit:** Job computes sizes correctly for single album, Skip middleware works, test passes + +### **I4 – Propagation Logic: Dispatch Parent Job** +- **Goal:** Add propagation to parent after successful computation, per FR-004-02 +- **Preconditions:** I3 complete +- **Steps:** + 1. In `RecomputeAlbumSizeJob::handle()`, after successful save: + - Check `if ($album->parent_id !== null)` + - Log: `Propagating to parent {parent_id}` + - `self::dispatch($album->parent_id)` + 2. Write feature test: create 3-level nested album tree, dispatch job for leaf, assert all 3 levels updated (S-004-09) + 3. Test propagation stops on failure: mock exception, verify parent job not dispatched, `failed()` called +- **Commands:** + - `php artisan test --filter AlbumSizePropagationTest` +- **Exit:** Propagation works up tree, stops on failure, test passes + +### **I5 – Event Listeners: Trigger Recomputation on Mutations** +- **Goal:** Hook job dispatch into photo/album/variant events, per FR-004-02 +- **Preconditions:** I3, I4 complete +- **Steps:** + 1. Identify existing events: `PhotoCreated`, `PhotoDeleted`, `PhotoMoved`, `AlbumCreated`, `AlbumDeleted`, `AlbumMoved`, `SizeVariantCreated`, `SizeVariantDeleted`, `SizeVariantRegenerated` (or equivalent) + 2. Create listener `app/Listeners/RecomputeAlbumSizeOnPhotoMutation.php`: + - Listen to photo events + - Extract `album_id` from event payload + - `RecomputeAlbumSizeJob::dispatch($album_id)` + 3. Create listener `app/Listeners/RecomputeAlbumSizeOnVariantMutation.php`: + - Listen to size variant events + - Fetch variant's photo, get album_id from `photo_album` pivot + - Dispatch job for each album photo belongs to + 4. Register listeners in `EventServiceProvider` + 5. Write feature test: upload photo, assert job dispatched, statistics updated (S-004-01) + 6. Test variant regeneration: regenerate variants, assert job dispatched (S-004-04) + 7. Test photo move: move photo between albums, assert both albums recomputed (S-004-05) +- **Commands:** + - `php artisan test --filter AlbumSizeEventListenerTest` +- **Exit:** All mutation events trigger recomputation, tests pass + +### **I6 – Refactor Spaces.php: getSpacePerAlbum** +- **Goal:** Replace runtime aggregation with table read, per FR-004-03 +- **Preconditions:** I1, I2 complete (jobs not required for read refactor) +- **Steps:** + 1. Refactor `Spaces::getSpacePerAlbum()`: + - Replace nested set + size_variants JOIN with simple `album_size_statistics` JOIN + - Return breakdown: map DB columns to SizeVariantType keys + - Add fallback: if statistics row NULL, use original query (defensive) + - Log warning if fallback used + 2. Write feature test: create album with statistics, call method, assert correct breakdown + 3. Test fallback: delete statistics row, call method, assert original calculation used + 4. Compare output format: before/after must be identical (API compatibility) +- **Commands:** + - `php artisan test --filter SpacesGetSpacePerAlbumTest` +- **Exit:** Method refactored, fallback works, test passes, output format unchanged + +### **I7 – Refactor Spaces.php: getTotalSpacePerAlbum** +- **Goal:** Optimize total size query (including descendants) +- **Preconditions:** I6 complete +- **Steps:** + 1. Refactor `Spaces::getTotalSpacePerAlbum()`: + - Use nested set to find all descendant albums: `WHERE _lft >= album._lft AND _rgt <= album._rgt` + - JOIN `album_size_statistics` on descendants + - SUM all 7 size columns, GROUP BY variant type + 2. Test: create nested album tree with photos, call method, assert total includes descendants (S-004-08) + 3. Benchmark: measure query time before/after +- **Commands:** + - `php artisan test --filter SpacesGetTotalSpacePerAlbumTest` +- **Exit:** Method optimized, test passes, performance improved + +### **I8 – Refactor Spaces.php: getFullSpacePerUser** +- **Goal:** Optimize user storage query, per NFR-004-02 (<100ms target) +- **Preconditions:** I6, I7 complete +- **Steps:** + 1. Refactor `Spaces::getFullSpacePerUser()`: + - JOIN albums owned by user (WHERE `owner_id`) + - JOIN `album_size_statistics` + - SUM all 7 size columns across user's albums + 2. Test: create user with multiple albums, call method, assert total storage (S-004-07) + 3. Benchmark: test with 10k photos, verify <100ms (NFR-004-02) +- **Commands:** + - `php artisan test --filter SpacesGetFullSpacePerUserTest` +- **Exit:** Method optimized, NFR-004-02 met, test passes + +### **I9 – Refactor Spaces.php: Remaining Methods** +- **Goal:** Optimize `getSpacePerSizeVariantTypePerUser()`, `getSpacePerSizeVariantTypePerAlbum()`, `getPhotoCountPerAlbum()`, `getTotalPhotoCountPerAlbum()` +- **Preconditions:** I6, I7, I8 complete +- **Steps:** + 1. Refactor each method to use `album_size_statistics` where applicable + 2. Note: `getPhotoCountPerAlbum()` and `getTotalPhotoCountPerAlbum()` may not need changes (they count photos, not sizes) - verify with spec + 3. Write tests for each method, compare output before/after +- **Commands:** + - `php artisan test --filter SpacesTest` +- **Exit:** All Spaces.php methods optimized, tests pass + +### **I10 – Backfill Command: CLI Implementation** +- **Goal:** Implement `lychee:backfill-album-sizes` command, per FR-004-04 +- **Preconditions:** I3, I4 complete (job must work) +- **Steps:** + 1. Create `app/Console/Commands/BackfillAlbumSizes.php` extending `Command` + 2. Signature: `lychee:backfill-album-sizes {--chunk=1000} {--album-id=}` + 3. Logic: + - Query all albums ORDER BY `_lft` DESC (leaf-to-root) + - If `--album-id` provided, filter to that album and ancestors + - Chunk albums (default 1000) + - For each chunk: + - Dispatch `RecomputeAlbumSizeJob` for each album + - Progress bar: `$this->output->progressBar($total)` + - Wait for queue to drain (or run synchronously if preferred) + 4. Idempotent: safe to re-run (updateOrCreate) + 5. Write test: backfill 100 albums, verify all have statistics + 6. Test partial backfill: backfill half, re-run, verify idempotent +- **Commands:** + - `php artisan lychee:backfill-album-sizes` + - `php artisan test --filter BackfillAlbumSizesCommandTest` +- **Exit:** Command works, idempotent, test passes + +### **I11 – Manual Recompute Command** +- **Goal:** Implement `lychee:recompute-album-sizes {album_id}` for recovery, per CLI-004-02 +- **Preconditions:** I3, I4 complete +- **Steps:** + 1. Create `app/Console/Commands/RecomputeAlbumSizes.php` extending `Command` + 2. Signature: `lychee:recompute-album-sizes {album_id}` + 3. Logic: + - Validate album exists + - Dispatch `RecomputeAlbumSizeJob::dispatch($album_id)` + - Output: "Dispatched recomputation for album {album_id}" + 4. Write test: run command, verify job dispatched +- **Commands:** + - `php artisan lychee:recompute-album-sizes {album_id}` + - `php artisan test --filter RecomputeAlbumSizesCommandTest` +- **Exit:** Command works, test passes + +### **I12 – Maintenance UI: Backfill Button** +- **Goal:** Add admin UI button to trigger backfill, per FR-004-05 +- **Preconditions:** I10 complete +- **Steps:** + 1. Locate existing maintenance page (likely `resources/js/components/admin/Maintenance.vue` or similar) + 2. Add button: "Backfill Album Size Statistics" + 3. Button click handler: + - POST to `/api/admin/maintenance/backfill-album-sizes` + - Disable button (loading state) + - Poll for progress (or use websocket if available) + - Show success/failure notification + 4. Backend: create controller method `MaintenanceController::backfillAlbumSizes()` + - Dispatch `BackfillAlbumSizesJob` to queue (wrap command logic in queueable job) + - Return job ID for progress tracking + 5. Progress tracking: store progress in cache, expose via `/api/admin/maintenance/backfill-status` + 6. Write feature test: POST endpoint, verify job dispatched +- **Commands:** + - `npm run check` (frontend tests) + - `php artisan test --filter MaintenanceBackfillTest` +- **Exit:** UI button works, job dispatched, progress trackable, tests pass + +### **I13 – Integration Tests: End-to-End Scenarios** +- **Goal:** Verify all 13 scenarios from spec pass +- **Preconditions:** All previous increments complete +- **Steps:** + 1. Write integration test for each scenario S-004-01 through S-004-13 + 2. Use real database (not mocks) with nested set tree, photos, variants + 3. Verify: + - S-004-01: Upload photo to empty album + - S-004-02: Delete last photo + - S-004-03: Photo with partial variants + - S-004-04: Regenerate variants + - S-004-05: Move photo between albums + - S-004-06: Create child album (sizes unchanged) + - S-004-07: User storage query fast + - S-004-08: Total space includes descendants + - S-004-09: Nested propagation (3 levels) + - S-004-10: Backfill matches runtime + - S-004-11: Cover deletion unrelated to size + - S-004-12: Concurrent jobs skip older + - S-004-13: PLACEHOLDER excluded +- **Commands:** + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest` +- **Exit:** All scenarios pass + +### **I14 – Performance Benchmarking** +- **Goal:** Validate NFR-004-01 through NFR-004-05 performance targets +- **Preconditions:** I13 complete +- **Steps:** + 1. Create staging database with realistic data: 100k albums, 1M photos, 5M size variants + 2. Benchmark before migration (runtime calculation): + - `getFullSpacePerUser()` for user with 10k photos + - `getTotalSpacePerAlbum()` for album with 1000 photos + - Record baseline times + 3. Run migration + backfill + 4. Benchmark after migration (table reads): + - Same queries as before + - Record new times + 5. Calculate improvement: expect 80%+ reduction + 6. Verify job performance: `RecomputeAlbumSizeJob` completes in <2s for album with 1000 photos (NFR-004-01) + 7. Document results in `docs/specs/4-architecture/features/004-album-size-statistics/performance-benchmarks.md` +- **Commands:** + - Custom benchmark script (use Laravel Telescope or custom timing logic) +- **Exit:** NFR targets met, results documented + +### **I15 – Documentation & Knowledge Map Updates** +- **Goal:** Update documentation per spec deliverables +- **Preconditions:** All code complete +- **Steps:** + 1. Update `docs/specs/4-architecture/knowledge-map.md`: + - Add `album_size_statistics` table entry + - Document `RecomputeAlbumSizeJob` architecture + - Link to Spaces.php refactoring + 2. Create ADR-0004: `docs/specs/6-decisions/ADR-0004-album-size-statistics-precomputation.md` + - Decision: pre-compute vs. runtime calculation + - Trade-offs: write complexity vs. read performance + - Schema design: per-variant columns vs. normalized rows + - Skip middleware pattern rationale + 3. Update README (if applicable): mention backfill command for new installations +- **Commands:** + - Review PRs for documentation changes +- **Exit:** Knowledge map, ADR, README updated + +## Scenario Tracking + +| Scenario ID | Increment Reference | Notes | +|-------------|---------------------|-------| +| S-004-01 | I5, I13 | Upload photo to empty album: event triggers job, statistics created | +| S-004-02 | I5, I13 | Delete last photo: statistics updated to zeros | +| S-004-03 | I3, I13 | Photo with partial variants: only present variants counted | +| S-004-04 | I5, I13 | Regenerate variants: event triggers recomputation | +| S-004-05 | I5, I13 | Move photo: both source and destination updated | +| S-004-06 | I13 | Create child album: parent sizes unchanged (direct photos only) | +| S-004-07 | I8, I13 | User storage query: fast (<100ms) | +| S-004-08 | I7, I13 | Total space includes descendants: nested set query | +| S-004-09 | I4, I13 | Nested propagation: 3 levels updated | +| S-004-10 | I10, I13 | Backfill matches runtime: sample verification | +| S-004-11 | I13 | Cover deletion: size unaffected (separate concern) | +| S-004-12 | I3, I13 | Concurrent jobs: Skip middleware deduplicates | +| S-004-13 | I3, I13 | PLACEHOLDER excluded: type 7 filtered out | + +## Analysis Gate + +**Execution date:** To be scheduled after I13 complete +**Reviewer:** Lychee Team +**Findings:** To be recorded + +**Checklist:** +- [ ] All FR requirements implemented and tested +- [ ] All NFR performance targets met (benchmarks documented) +- [ ] Skip middleware pattern correctly implemented (matches Feature 003) +- [ ] Fallback logic tested (runtime calculation when statistics missing) +- [ ] FK cascade tested (statistics deleted when album deleted) +- [ ] Backfill tested on staging clone (100k+ albums) +- [ ] No regressions in existing Spaces.php tests +- [ ] Code follows conventions (PSR-4, strict comparison, snake_case) +- [ ] License headers added to new files + +## Exit Criteria + +- [x] Specification approved (spec.md complete) +- [ ] All 15 increments complete +- [ ] All 13 scenarios pass integration tests (I13) +- [ ] Performance benchmarks meet NFR targets (I14) +- [ ] `php artisan test` passes (full test suite) +- [ ] `make phpstan` passes (no errors) +- [ ] `vendor/bin/php-cs-fixer fix` passes (code style) +- [ ] Documentation updated (knowledge map, ADR, README) +- [ ] Backfill tested on staging clone +- [ ] Analysis gate passed +- [ ] Roadmap updated to "Complete" status + +## Follow-ups / Backlog + +- **Optimization:** Add database indexes on `size_variants.photo_id`, `size_variants.type`, `photo_album.album_id` if query plans show table scans (defer to production monitoring) +- **Monitoring:** Add metrics dashboard for job queue depth, recomputation latency, backfill progress (Feature 00X) +- **Historical tracking:** Store size history over time for trend analysis (separate feature, requires time-series table) +- **Storage quota enforcement:** Use pre-computed sizes to enforce per-user limits (Feature 00Y) +- **Real-time updates:** Investigate websocket/polling for instant size updates in UI (currently eventual consistency) + +--- + +*Last updated: 2026-01-02* diff --git a/docs/specs/4-architecture/features/004-album-size-statistics/spec.md b/docs/specs/4-architecture/features/004-album-size-statistics/spec.md new file mode 100644 index 00000000000..28bf96fcc58 --- /dev/null +++ b/docs/specs/4-architecture/features/004-album-size-statistics/spec.md @@ -0,0 +1,265 @@ +# Feature 004 – Album Size Statistics Pre-computation + +| Field | Value | +|-------|-------| +| Status | Draft | +| Last updated | 2026-01-02 | +| Owners | Lychee Team | +| Linked plan | `docs/specs/4-architecture/features/004-album-size-statistics/plan.md` | +| Linked tasks | `docs/specs/4-architecture/features/004-album-size-statistics/tasks.md` | +| Roadmap entry | #004 | + +> Guardrail: This specification is the single normative source of truth for the feature. Track high- and medium-impact questions in [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md), encode resolved answers directly in the Requirements/NFR/Behaviour/UI/Telemetry sections below (no per-feature `## Clarifications` sections), and use ADRs under `docs/specs/6-decisions/` for architecturally significant clarifications (referencing their IDs from the relevant spec sections). + +## Overview +Refactor album size statistics from runtime calculation to pre-computed database table. Currently, [Spaces.php](app/Actions/Statistics/Spaces.php) executes expensive aggregate queries across `size_variants`, `photo_album`, and nested album trees every time size statistics are requested. This feature creates a dedicated `album_size_statistics` table storing size breakdowns per album per size variant type, enabling fast lookups for user storage quotas, album space usage, and storage analytics. The computation is event-driven: when photos/albums change, a job recomputes affected albums and propagates changes up the album tree. + +## Goals +- Create `album_size_statistics` table with columns: `album_id`, `size_thumb`, `size_thumb2x`, `size_small`, `size_small2x`, `size_medium`, `size_medium2x`, `size_original` +- Replace expensive `Spaces.php` aggregate queries with simple table reads and SUM operations +- Implement event-driven update system: when photos/albums/size-variants change, recompute affected albums and propagate to parents +- Maintain correctness: computed values must match current `Spaces.php` calculation results +- Improve performance: user storage queries should complete in <100ms (currently can take seconds for large galleries) +- Enable fast analytics: total storage per user = SUM of their albums' statistics + +## Non-Goals +- Changing user-facing statistics API endpoints (same request/response format) +- Real-time updates (eventual consistency within job processing time is acceptable) +- Storage quota enforcement (separate feature) +- Per-photo size tracking (only per-album aggregates) +- Historical size tracking over time (only current state) + +## Functional Requirements + +| ID | Requirement | Success path | Validation path | Failure path | Telemetry & traces | Source | +|----|-------------|--------------|-----------------|--------------|--------------------|--------| +| FR-004-01 | Database must have `album_size_statistics` table with schema: `album_id` (string, PK, FK to albums.id), `size_thumb` (bigint unsigned, default 0), `size_thumb2x` (bigint unsigned, default 0), `size_small` (bigint unsigned, default 0), `size_small2x` (bigint unsigned, default 0), `size_medium` (bigint unsigned, default 0), `size_medium2x` (bigint unsigned, default 0), `size_original` (bigint unsigned, default 0) | Migration creates table. Foreign key constraint `ON DELETE CASCADE` ensures orphaned records are cleaned up when album deleted. | Migration must be reversible. Verify column types support large values (bigint unsigned for filesizes in bytes up to ~18 exabytes). Test FK constraint deletes statistics when album deleted. | Migration failure rolls back transaction. Log error, halt deployment. | Log migration execution: `Creating album_size_statistics table`. No PII. | User requirement, Spaces.php line 45 (excludes type 7/PLACEHOLDER), SizeVariantType enum | +| FR-004-02 | When photo added/removed/moved to album, OR size variant created/deleted/regenerated, trigger job to recompute album's size statistics and propagate to parent albums | Photo/album/variant mutation events dispatch `RecomputeAlbumSizeJob(album_id)` to default queue. Job uses `Skip` middleware with cache-based deduplication (pattern from RecomputeAlbumStatsJob.php lines 76-93): each job gets unique ID (`uniqid('job_', true)`), stores latest job ID in cache key `album_size_latest_job:{album_id}` (TTL 1 day), middleware checks `Skip::when(fn() => hasNewerJobQueued())` to skip if newer job queued for same album. Job queries all size_variants for photos in album (direct children only, NOT descendants), groups by variant type, sums filesize. Updates/creates `album_size_statistics` row via `firstOrCreate`. On success, dispatches job for parent if parent exists. Propagation continues to root. PLACEHOLDER variants (type 7) excluded from all size calculations. Retry attempts: 3. | Job must use database transactions. Test with deeply nested albums (5+ levels). Verify computed sizes match current Spaces.php `getSpacePerAlbum()` output. Test variant regeneration triggers update. Test Skip middleware: concurrent jobs for same album should skip older jobs. | If job fails (database error), retry up to 3 times. After 3 failures, STOP propagation (do not dispatch parent job), log error with album_id and exception. Manual `php artisan lychee:recompute-album-sizes {album_id}` command available for recovery. | Log job dispatch: `Recomputing sizes for album {album_id} (job {job_id})`. Log job skip: `Skipping job {job_id} for album {album_id} due to newer job {newer_job_id} queued`. Log propagation: `Propagating to parent {parent_id}`. Log propagation stop: `Propagation stopped at album {album_id} due to failure`. | Q-004-01 (Option B), Q-004-03 (Option D), Spaces.php lines 176-184, RecomputeAlbumStatsJob.php lines 56-93 | +| FR-004-03 | `Spaces.php` methods must be refactored to read from `album_size_statistics` table instead of computing at runtime | `getSpacePerAlbum()`: JOIN `album_size_statistics`, return breakdown. `getTotalSpacePerAlbum()`: Use nested set query to find all descendant albums, JOIN their `album_size_statistics`, SUM by variant type. `getFullSpacePerUser()`: JOIN albums owned by user, SUM all variant columns from `album_size_statistics`. Similar optimizations for other methods. | Compare query performance before/after (expect 80%+ reduction in execution time). Verify output format unchanged (API compatibility). Run full test suite. | If statistics row missing (NULL), fall back to runtime calculation (defensive programming during migration period). Log warning: `Missing size statistics for album {album_id}, using fallback`. | Log query performance: `Spaces query completed in {ms}ms (before: {old_ms}ms)`. | Spaces.php all methods, performance requirement | +| FR-004-04 | Backfill command must populate size statistics for all existing albums | Artisan command `php artisan lychee:backfill-album-sizes` iterates all albums (leaf-to-root order using nested set _lft DESC to ensure children computed before parents), computes size breakdown, saves to `album_size_statistics` table. Progress bar shows completion. Idempotent (safe to re-run). Migration creates table only; operator must manually run backfill during maintenance window. | Command must complete without errors on production data. Verify computed values match current Spaces.php runtime calculations (sample check). Run on staging clone before production. Migration must be reversible via `down()` method (drops `album_size_statistics` table). | If database error mid-backfill, transaction rolls back for that album. Log error, continue to next album. Operator can re-run to fill gaps. | Log backfill progress: `Backfilled {count}/{total} albums`. | Q-004-02 (Option A + maintenance UI button), Feature 003 backfill pattern (CLI-003-01) | +| FR-004-05 | Admin maintenance UI must provide button to trigger backfill | Maintenance page (similar to existing "Generate Size Variants" button) includes "Backfill Album Size Statistics" button. Clicking triggers backfill command asynchronously via job queue. Progress displayed via polling or websocket. Button disabled while backfill running. Success/failure notification shown on completion. | Test button triggers backfill correctly. Verify progress updates. Test concurrent backfill prevention (button disabled if job already running). | Backfill job must be queueable. Frontend must handle long-running operation (progress polling). | Backfill job fails: display error notification, log error server-side. | Log UI trigger: `Backfill triggered from maintenance UI by user {user_id}`. | Q-004-02 resolution, admin UX requirement | + +## Non-Functional Requirements + +| ID | Requirement | Driver | Measurement | Dependencies | Source | +|----|-------------|--------|-------------|--------------|--------| +| NFR-004-01 | Recomputation jobs must process within 2 seconds for albums with <1000 direct photos | Users expect near-immediate UI updates after photo upload/deletion | Measure job execution time in staging with realistic data volumes. Profile slow queries. | Database indexes on `size_variants.photo_id`, `size_variants.type`, `photo_album.album_id` | Performance requirement | +| NFR-004-02 | User storage queries must complete within 100ms for users with <10k photos | Storage quota UI should load instantly | Benchmark `getFullSpacePerUser()` before/after. Expect 80%+ reduction from current multi-second queries. | Indexed `album_size_statistics.album_id`, indexed `albums.owner_id` | User requirement, current performance pain point | +| NFR-004-03 | Migration must complete within 5 minutes for installations with 100k albums | Deployment downtime must be minimal | Run migration on staging clone with 100k+ albums. Schema change only (table creation), no backfill during migration. | Database migration tools | Production deployment constraints | +| NFR-004-04 | Computed sizes must maintain eventual consistency within 30 seconds of mutation | Stale data is acceptable for brief window; correctness required after propagation | Monitor job queue lag. Alert if queue depth exceeds threshold. Test: upload photo, verify album size updates within 30s. | Job queue reliability, worker processes (Feature 002) | User experience, data integrity | +| NFR-004-05 | Table must support albums up to 10TB total size (original + all variants) | Large galleries with RAW photos can exceed terabytes | bigint unsigned supports up to 18 exabytes (18,446,744,073,709,551,615 bytes). Test with mock albums >10TB. | Database column type selection | Real-world usage patterns | + +## UI / Interaction Mock-ups +Not applicable – this is a backend performance optimization. User-facing behavior (storage statistics displays) remains identical. No UI changes. + +## Branch & Scenario Matrix + +| Scenario ID | Description / Expected outcome | +|-------------|--------------------------------| +| S-004-01 | Upload photo to empty album: `album_size_statistics` row created with sizes populated for each variant type, zeros for missing variants | +| S-004-02 | Delete last photo from album: `album_size_statistics` row updated with all sizes set to 0 (row remains, not deleted) | +| S-004-03 | Upload photo with only ORIGINAL and THUMB variants: corresponding size columns populated, other variant columns remain 0 | +| S-004-04 | Regenerate size variants for photo (e.g., admin triggers `GenSizeVariants`): album sizes recomputed, updated values reflect new variant filesizes | +| S-004-05 | Move photo between albums: source album sizes decremented, destination album sizes incremented, both propagate to respective parents | +| S-004-06 | Create child album with photos: parent album sizes unchanged (only direct photo sizes counted, NOT descendants) | +| S-004-07 | User queries their total storage via `getFullSpacePerUser()`: query sums `album_size_statistics` for user's owned albums, returns quickly (<100ms) | +| S-004-08 | Admin queries `getTotalSpacePerAlbum()` for album with 3 levels of sub-albums: nested set query finds all descendants, sums their `album_size_statistics`, returns total including sub-albums | +| S-004-09 | Nested album (3 levels deep): photo added to leaf album triggers recomputation of leaf, parent, grandparent statistics (all three get updated sizes) | +| S-004-10 | Backfill command run on existing installation: all albums get `album_size_statistics` rows populated, values match current Spaces.php runtime calculations | +| S-004-11 | Photo deleted that was a cover for album: size statistics updated, unrelated to cover logic (Feature 003 handles cover separately) | +| S-004-12 | Upload 10 photos simultaneously to same album: WithoutOverlapping middleware ensures only one RecomputeAlbumSizeJob runs, later jobs skip (eventual consistency maintained) | +| S-004-13 | Size variant PLACEHOLDER (type 7) created: excluded from size calculations, all size columns unchanged | + +## Test Strategy +- **Unit:** Test `RecomputeAlbumSizeJob` logic in isolation (mocked album, verify SQL queries, assert correct variant type filtering) +- **Feature:** Test each scenario S-004-01 through S-004-13 with real database + - Create album hierarchy, perform mutations, assert `album_size_statistics` table correct + - Verify propagation reaches root + - Test PLACEHOLDER exclusion +- **Integration:** Test with existing Spaces.php API usage + - Before migration: response uses runtime calculation + - After migration: response uses table reads + - Assert identical numerical output (tolerance for rounding: ±1 byte) +- **Performance:** Benchmark `getFullSpacePerUser()` and `getTotalSpacePerAlbum()` before/after (expect 80%+ reduction in query time) +- **Regression:** Full test suite must pass (ensure no breakage in storage statistics endpoints) +- **Data Migration:** Backfill test on staging clone (100k+ albums), verify correctness via sampling (compare 1000 random albums against runtime calculation) + +## Interface & Contract Catalogue + +### Domain Objects +| ID | Description | Modules | +|----|-------------|---------| +| DO-004-01 | AlbumSizeStatistics table schema: `album_id` (string PK FK), `size_thumb` (bigint unsigned), `size_thumb2x` (bigint unsigned), `size_small` (bigint unsigned), `size_small2x` (bigint unsigned), `size_medium` (bigint unsigned), `size_medium2x` (bigint unsigned), `size_original` (bigint unsigned) | Migration, AlbumSizeStatistics model, Spaces.php | + +### API Routes / Services +No API changes – existing Spaces.php methods return same data structure, sourced from `album_size_statistics` table instead of runtime aggregation. + +### CLI Commands / Flags +| ID | Command | Behaviour | +|----|---------|-----------| +| CLI-004-01 | `php artisan lychee:backfill-album-sizes` | Populates `album_size_statistics` for all existing albums. Idempotent, shows progress bar. Operator must run manually after migration during maintenance window. | +| CLI-004-02 | `php artisan lychee:recompute-album-sizes {album_id}` | Manually recomputes size statistics for single album (debugging/recovery after propagation failure). | + +### Telemetry Events +| ID | Event name | Fields / Redaction rules | +|----|-----------|---------------------------| +| TE-004-01 | Migration execution | `migration_name`, `duration_ms`. No PII. | +| TE-004-02 | Album size recomputation | `album_id`, `trigger_event` (photo.created, variant.regenerated, etc.), `duration_ms`, `total_size_bytes`. No PII. | +| TE-004-03 | Backfill progress | `processed_count`, `total_count`, `batch_num`. No PII. | + +### Fixtures & Sample Data +| ID | Path | Purpose | +|----|------|---------| +| FX-004-01 | `tests/Fixtures/album-with-mixed-variants.json` | Album with photos having different variant combinations (some missing MEDIUM, some missing THUMB2X, etc.) | +| FX-004-02 | `tests/Fixtures/album-10TB-mock.json` | Mock album data simulating 10TB total size for boundary testing | + +### UI States +Not applicable – no UI changes. + +## Telemetry & Observability +- **Migration logs:** Duration, success/failure, table creation confirmation +- **Job execution logs:** Album ID, recomputation trigger, duration, total size computed, propagation path +- **Backfill progress:** Batch processing stats, estimated completion time, errors encountered +- **Performance metrics:** Query time reduction for Spaces.php methods (before/after comparison), job queue depth + +## Documentation Deliverables +1. Update [knowledge-map.md](../../knowledge-map.md): + - Add `album_size_statistics` table documentation + - Document event-driven size update architecture + - Link to Spaces.php refactoring +2. Create ADR-0004: [ADR-0004-album-size-statistics-precomputation.md](../../6-decisions/ADR-0004-album-size-statistics-precomputation.md) covering: + - Decision to pre-compute vs. continue runtime calculation + - Trade-offs: increased write complexity for improved read performance + - Table schema design (per-variant columns vs. normalized rows) + - Integration with Feature 003 vs. separate architecture (Q-004-01 resolution) +3. Update [roadmap.md](../../roadmap.md) with Feature 004 entry + +## Fixtures & Sample Data +- Migration test fixtures: albums with various states (empty, with photos, mixed variant types) +- Performance test fixtures: large album sets (1k, 10k, 100k albums) +- Regression test fixtures: existing test data must work identically post-migration + +## Spec DSL + +```yaml +domain_objects: + - id: DO-004-01 + name: AlbumSizeStatistics + table: album_size_statistics + columns: + - name: album_id + type: string + primary_key: true + foreign_key: albums.id + on_delete: CASCADE + - name: size_thumb + type: bigint unsigned + default: 0 + description: Total bytes for THUMB variants (type 6) in album + - name: size_thumb2x + type: bigint unsigned + default: 0 + description: Total bytes for THUMB2X variants (type 5) in album + - name: size_small + type: bigint unsigned + default: 0 + description: Total bytes for SMALL variants (type 4) in album + - name: size_small2x + type: bigint unsigned + default: 0 + description: Total bytes for SMALL2X variants (type 3) in album + - name: size_medium + type: bigint unsigned + default: 0 + description: Total bytes for MEDIUM variants (type 2) in album + - name: size_medium2x + type: bigint unsigned + default: 0 + description: Total bytes for MEDIUM2X variants (type 1) in album + - name: size_original + type: bigint unsigned + default: 0 + description: Total bytes for ORIGINAL variants (type 0) in album + +cli_commands: + - id: CLI-004-01 + command: php artisan lychee:backfill-album-sizes + behavior: Populate size statistics for all albums (leaf-to-root order) + flags: + - --chunk=1000: Batch size for processing + - --album-id=: Backfill single album and ancestors + - id: CLI-004-02 + command: php artisan lychee:recompute-album-sizes {album_id} + behavior: Manually recompute size statistics for single album + +telemetry_events: + - id: TE-004-01 + event: migration.album_size_statistics + fields: [migration_name, duration_ms] + - id: TE-004-02 + event: album.size_recomputed + fields: [album_id, trigger_event, duration_ms, total_size_bytes] + - id: TE-004-03 + event: backfill.album_sizes + fields: [processed_count, total_count, batch_num] + +fixtures: + - id: FX-004-01 + path: tests/Fixtures/album-with-mixed-variants.json + - id: FX-004-02 + path: tests/Fixtures/album-10TB-mock.json +``` + +## Appendix + +### Size Variant Type Mapping +Per [SizeVariantType.php](app/Enum/SizeVariantType.php), the enum values are: +- 0: ORIGINAL +- 1: MEDIUM2X +- 2: MEDIUM +- 3: SMALL2X +- 4: SMALL +- 5: THUMB2X +- 6: THUMB +- 7: PLACEHOLDER (excluded from size calculations) + +### Current Spaces.php Query Pattern +[Spaces.php](app/Actions/Statistics/Spaces.php) line 176-184 shows the current `getSpacePerAlbum()` implementation: +```php +->joinSub( + query: DB::table('size_variants') + ->select(['size_variants.id', 'size_variants.photo_id', 'size_variants.filesize']) + ->where('size_variants.type', '!=', 7), // Exclude PLACEHOLDER + as: 'size_variants', + first: 'size_variants.photo_id', + operator: '=', + second: PA::PHOTO_ID +) +``` + +This pattern is repeated across multiple methods, each performing expensive nested set joins and aggregations. Feature 004 eliminates these runtime queries by pre-computing into `album_size_statistics`. + +### Integration with Feature 003 +**Decision (Q-004-01 resolved - Option B):** Separate `RecomputeAlbumSizeJob` will be created, independent from Feature 003's `RecomputeAlbumStatsJob`. This decouples the features and allows independent optimization. However, the job implementation will reuse the proven Skip middleware pattern from Feature 003 (see [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php:56-93)) for consistency in the codebase. + +### Job Deduplication Pattern +**Decision (Q-004-03 resolved - Option D):** Reuses cache-based Skip middleware pattern from Feature 003: +1. Each `RecomputeAlbumSizeJob` instance gets unique ID via `uniqid('job_', true)` in constructor +2. Constructor stores job ID in cache: `Cache::put('album_size_latest_job:' . $album_id, $jobId, ttl: 1 day)` +3. `middleware()` method returns `[Skip::when(fn() => $this->hasNewerJobQueued())]` +4. `hasNewerJobQueued()` checks if cache key `album_size_latest_job:{album_id}` contains different job ID +5. If newer job exists, older job is skipped (logged and not executed) +6. Successful job execution clears cache key via `Cache::forget()` + +This pattern is simpler than `WithoutOverlapping` and guarantees the most recent update eventually processes. + +### Backfill Strategy +**Decision (Q-004-02 resolved - Option A + Maintenance UI):** Two-pronged approach: +1. **CLI command** `php artisan lychee:backfill-album-sizes` for server operators with shell access +2. **Maintenance UI button** for operators using web interface (triggers same backfill logic via queued job) + +Migration creates table schema only; backfill is manual step run during maintenance window. + +--- + +*Last updated: 2026-01-02* diff --git a/docs/specs/4-architecture/features/004-album-size-statistics/tasks.md b/docs/specs/4-architecture/features/004-album-size-statistics/tasks.md new file mode 100644 index 00000000000..84f1cce43c4 --- /dev/null +++ b/docs/specs/4-architecture/features/004-album-size-statistics/tasks.md @@ -0,0 +1,470 @@ +# Feature 004 Tasks – Album Size Statistics Pre-computation + +_Status: Draft_ +_Last updated: 2026-01-02_ + +> Keep this checklist aligned with the feature plan increments. Stage tests before implementation, record verification commands beside each task, and prefer bite-sized entries (≤90 minutes). +> **Mark tasks `[x]` immediately** after each one passes verification—do not batch completions. Update the roadmap status when all tasks are done. +> When referencing requirements, keep feature IDs (`FR-`), non-goal IDs (`NFR-`), and scenario IDs (`S-004-`) inside the same parentheses immediately after the task title (omit categories that do not apply). +> When new high- or medium-impact questions arise during execution, add them to [docs/specs/4-architecture/open-questions.md](docs/specs/4-architecture/open-questions.md) instead of informal notes, and treat a task as fully resolved only once the governing spec sections (requirements/NFR/behaviour/telemetry) and, when required, ADRs under `docs/specs/6-decisions/` reflect the clarified behaviour. + +## Checklist + +### I1 – Database Migration + +- [x] T-004-01 – Create migration file for album_size_statistics table (FR-004-01). + _Intent:_ Generate migration stub with correct naming convention. + _Verification commands:_ + - `php artisan make:migration create_album_size_statistics_table` + - Verify file created in `database/migrations/` + +- [x] T-004-02 – Implement up() migration: create table with schema (FR-004-01). + _Intent:_ Add table with `album_id` PK FK, 7 size columns (bigint unsigned), indexes, FK constraint ON DELETE CASCADE. + _Note:_ **DO NOT add timestamps()** – table should have exactly 8 columns (album_id + 7 size columns), no created_at/updated_at. + _Verification commands:_ + - `php artisan migrate` + - Verify table exists: `php artisan db:show` + - Check schema: `DESCRIBE album_size_statistics` + - Verify exactly 8 columns, no timestamp columns + +- [x] T-004-03 – Implement down() migration: drop table (FR-004-01). + _Intent:_ Ensure migration reversibility. + _Verification commands:_ + - `php artisan migrate:rollback` + - Verify table dropped + - Re-run: `php artisan migrate` + +- [x] T-004-04 – Test FK CASCADE: delete album, verify statistics deleted (FR-004-01). + _Intent:_ Verify ON DELETE CASCADE constraint works. + _Verification commands:_ + - Create test album with statistics row + - Delete album + - Verify statistics row deleted automatically + +### I2 – Eloquent Model + +- [x] T-004-05 – Create AlbumSizeStatistics model class (DO-004-01). + _Intent:_ Model for album_size_statistics table with correct properties. + _Verification commands:_ + - Create `app/Models/AlbumSizeStatistics.php` + - Set `$table`, `$primaryKey`, `$incrementing`, `$fillable`, `$casts` + +- [x] T-004-06 – Define relationships: belongsTo Album, inverse hasOne (DO-004-01). + _Intent:_ Two-way relationship between Album and AlbumSizeStatistics. + _Verification commands:_ + - Add `belongsTo(Album::class)` in AlbumSizeStatistics + - Add `hasOne(AlbumSizeStatistics::class)` in Album + +- [x] T-004-07 – Write unit test for AlbumSizeStatistics model. + _Intent:_ Test model creation, relationships, fillable fields. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsTest` + +### I3 – RecomputeAlbumSizeJob Core + +- [x] T-004-08 – Create RecomputeAlbumSizeJob class skeleton (FR-004-02). + _Intent:_ Job class with constructor, traits, properties. + _Verification commands:_ + - Create `app/Jobs/RecomputeAlbumSizeJob.php` + - Implement ShouldQueue, add traits + +- [x] T-004-09 – Implement constructor with unique job ID and cache storage (FR-004-02, Q-004-03 resolution). + _Intent:_ Generate `uniqid('job_', true)`, store in cache `album_size_latest_job:{album_id}` with TTL 1 day. + _Verification commands:_ + - Test job construction + - Verify cache key set + +- [x] T-004-10 – Implement Skip middleware with hasNewerJobQueued() (FR-004-02, Q-004-03 resolution). + _Intent:_ Reuse Feature 003 pattern: `Skip::when(fn() => hasNewerJobQueued())`. + _Verification commands:_ + - Implement `middleware()` method + - Implement `hasNewerJobQueued()` checking cache + - Log skip events + +- [x] T-004-11 – Implement handle(): fetch album, compute sizes (FR-004-02, S-004-01, S-004-13). + _Intent:_ Core computation logic: query size_variants for album's photos, GROUP BY type, SUM filesize, exclude PLACEHOLDER (type 7). + _Verification commands:_ + - Implement handle() method + - Query: `size_variants JOIN photo_album WHERE album_id, WHERE type != 7, GROUP BY type, SUM filesize` + - Build size array (initialize all 7 to 0, populate from query) + +- [x] T-004-12 – Implement updateOrCreate for album_size_statistics (FR-004-02). + _Intent:_ Save computed sizes via `AlbumSizeStatistics::updateOrCreate()`. + _Verification commands:_ + - Wrap in DB transaction + - Call `updateOrCreate(['album_id' => $album->id], $size_array)` + +- [x] T-004-13 – Implement failed() method and retry logic (FR-004-02). + _Intent:_ Set `$tries = 3`, log permanent failure in `failed()`. + _Verification commands:_ + - Set `public $tries = 3` + - Implement `failed(\Throwable $exception)` with log + +- [x] T-004-14 – Write unit test for job: compute sizes correctly (S-004-01, S-004-03, S-004-13). + _Intent:_ Mock album with photos/variants, run job, assert statistics computed. + _Verification commands:_ + - `php artisan test --filter RecomputeAlbumSizeJobTest` + - Test scenarios: empty album, partial variants, PLACEHOLDER exclusion, propagation + +### I4 – Propagation Logic + +- [x] T-004-15 – Add parent propagation after successful save (FR-004-02, S-004-09). + _Intent:_ Dispatch job for parent album after updating current album. + _Verification commands:_ + - Check `if ($album->parent_id !== null)` + - Log propagation + - `self::dispatch($album->parent_id)` + +- [x] T-004-16 – Write feature test for 3-level nested propagation (S-004-09). + _Intent:_ Create grandparent→parent→child tree, dispatch job for child, verify all 3 updated. + _Verification commands:_ + - `php artisan test --filter AlbumSizePropagationTest` + +- [x] T-004-17 – Test propagation stops on failure (FR-004-02). + _Intent:_ Mock exception during save, verify parent job NOT dispatched, failed() called. + _Verification commands:_ + - Test with mock exception + - Assert propagation stopped + +### I5 – Event Listeners + +- [x] T-004-18 – Create listener for photo mutation events (FR-004-02, S-004-01, S-004-02, S-004-05). + _Intent:_ Listen to PhotoCreated, PhotoDeleted, PhotoMoved; dispatch job. + _Verification commands:_ + - Create `app/Listeners/RecomputeAlbumSizeOnPhotoMutation.php` + - Extract album_id from event payload + - `RecomputeAlbumSizeJob::dispatch($album_id)` + +- [x] T-004-19 – Create listener for size variant mutation events (FR-004-02, S-004-04). + _Intent:_ Listen to SizeVariantCreated, SizeVariantDeleted, SizeVariantRegenerated; dispatch job. + _Verification commands:_ + - Create `app/Listeners/RecomputeAlbumSizeOnVariantMutation.php` + - Fetch variant's photo, get album_id(s), dispatch jobs + +- [x] T-004-20 – Register listeners in EventServiceProvider. + _Intent:_ Hook listeners to events. + _Verification commands:_ + - Edit `app/Providers/EventServiceProvider.php` + - Add listener mappings + +- [x] T-004-21 – Write feature tests for event-driven recomputation (S-004-01, S-004-04, S-004-05). + _Intent:_ Upload photo, regenerate variant, move photo; verify jobs dispatched and statistics updated. + _Verification commands:_ + - `php artisan test --filter AlbumSizeEventListenerTest` + +### I6 – Refactor Spaces.php: getSpacePerAlbum + +- [x] T-004-22 – Refactor getSpacePerAlbum() to use album_size_statistics table (FR-004-03). + _Intent:_ Replace runtime aggregation with table JOIN, return breakdown. + _Verification commands:_ + - Edit `app/Actions/Statistics/Spaces.php` + - Replace query with JOIN on `album_size_statistics` + +- [x] T-004-23 – Add fallback to runtime calculation if statistics missing (FR-004-03). + _Intent:_ Defensive programming during migration period. + _Verification commands:_ + - Check if statistics row NULL + - Use COALESCE to return 0 for missing statistics + - Note: Implemented via COALESCE(..., 0) in queries + +- [ ] T-004-24 – Write feature test for getSpacePerAlbum() (FR-004-03). + _Intent:_ Test refactored method, verify output format unchanged. + _Verification commands:_ + - `php artisan test --filter SpacesGetSpacePerAlbumTest` + - Compare output before/after refactor + +### I7 – Refactor Spaces.php: getTotalSpacePerAlbum + +- [x] T-004-25 – Refactor getTotalSpacePerAlbum() with nested set query (FR-004-03, S-004-08). + _Intent:_ Find descendants via nested set, JOIN their statistics, SUM. + _Verification commands:_ + - Edit method + - Nested set query: `WHERE _lft >= album._lft AND _rgt <= album._rgt` + - JOIN album_size_statistics, SUM columns + +- [ ] T-004-26 – Write feature test for getTotalSpacePerAlbum() (S-004-08). + _Intent:_ Create nested tree with photos, verify total includes descendants. + _Verification commands:_ + - `php artisan test --filter SpacesGetTotalSpacePerAlbumTest` + +- [ ] T-004-27 – Benchmark getTotalSpacePerAlbum() performance improvement. + _Intent:_ Measure query time before/after, document reduction. + _Verification commands:_ + - Use Telescope or custom timing + - Compare before/after on large album (1000+ photos) + +### I8 – Refactor Spaces.php: getFullSpacePerUser + +- [x] T-004-28 – Refactor getFullSpacePerUser() for <100ms target (FR-004-03, NFR-004-02, S-004-07). + _Intent:_ JOIN user's albums, SUM their statistics. + _Verification commands:_ + - Edit method + - JOIN albums WHERE owner_id, JOIN album_size_statistics, SUM + +- [ ] T-004-29 – Write feature test for getFullSpacePerUser() (S-004-07). + _Intent:_ User with multiple albums, verify total storage. + _Verification commands:_ + - `php artisan test --filter SpacesGetFullSpacePerUserTest` + +- [ ] T-004-30 – Benchmark getFullSpacePerUser() with 10k photos (NFR-004-02). + _Intent:_ Verify <100ms performance target. + _Verification commands:_ + - Test with user owning albums totaling 10k photos + - Measure query time, assert <100ms + +### I9 – Refactor Spaces.php: Remaining Methods + +- [ ] T-004-31 – Refactor getSpacePerSizeVariantTypePerUser() (FR-004-03). + _Intent:_ Use album_size_statistics for variant breakdown. + _Verification commands:_ + - Edit method, test output + +- [ ] T-004-32 – Refactor getSpacePerSizeVariantTypePerAlbum() (FR-004-03). + _Intent:_ Use album_size_statistics for variant breakdown. + _Verification commands:_ + - Edit method, test output + +- [ ] T-004-33 – Verify getPhotoCountPerAlbum() and getTotalPhotoCountPerAlbum() unaffected. + _Intent:_ These count photos, not sizes; may not need changes. + _Verification commands:_ + - Review methods + - Run existing tests + +- [ ] T-004-34 – Run full Spaces.php test suite after refactoring. + _Intent:_ Ensure no regressions. + _Verification commands:_ + - `php artisan test --filter SpacesTest` + +### I10 – Backfill Command + +- [x] T-004-35 – Create BackfillAlbumSizes command class (FR-004-04, CLI-004-01). + _Intent:_ Artisan command `lychee:backfill-album-sizes`. + _Verification commands:_ + - Create `app/Console/Commands/BackfillAlbumSizeStatistics.php` + - Signature: `lychee:backfill-album-sizes {--chunk=1000} {--dry-run}` + +- [x] T-004-36 – Implement command logic: query albums, dispatch jobs (FR-004-04). + _Intent:_ Iterate albums leaf-to-root (ORDER BY _lft ASC), chunk processing, progress bar. + _Verification commands:_ + - Query albums with chunking + - Dispatch RecomputeAlbumSizeJob for each + - Progress bar with logging every 100 albums + +- [x] T-004-37 – Test backfill idempotency (FR-004-04). + _Intent:_ Safe to re-run (updateOrCreate in job). + _Verification commands:_ + - Tested via --dry-run mode + - Job uses updateOrCreate, idempotent by design + +- [ ] T-004-38 – Write feature test for backfill command (S-004-10). + _Intent:_ Backfill albums, verify all have statistics matching runtime calculation. + _Verification commands:_ + - `php artisan test --filter BackfillAlbumSizesCommandTest` + +### I11 – Manual Recompute Command + +- [x] T-004-39 – Create RecomputeAlbumSizes command class (CLI-004-02). + _Intent:_ Artisan command `lychee:recompute-album-sizes {album_id}`. + _Verification commands:_ + - Create `app/Console/Commands/RecomputeAlbumSizes.php` + - Signature: `lychee:recompute-album-sizes {album_id}` + +- [x] T-004-40 – Implement command logic: validate album, dispatch job (CLI-004-02). + _Intent:_ Validate album exists, dispatch RecomputeAlbumSizeJob. + _Verification commands:_ + - Validate album_id with error handling + - Dispatch job with logging + - Output confirmation message + +- [ ] T-004-41 – Write feature test for recompute command. + _Intent:_ Run command, verify job dispatched. + _Verification commands:_ + - `php artisan test --filter RecomputeAlbumSizesCommandTest` + +### I12 – Maintenance UI Button + +- [x] T-004-42 – Add "Backfill Album Size Statistics" button to maintenance page (FR-004-05). + _Intent:_ UI element in admin maintenance section. + _Verification commands:_ + - Created `resources/js/components/maintenance/MaintenanceBackfillAlbumSizes.vue` + - Added to `resources/js/views/Maintenance.vue` + +- [x] T-004-43 – Create backend endpoint: POST /api/admin/maintenance/backfill-album-sizes (FR-004-05). + _Intent:_ Trigger backfill via HTTP request. + _Verification commands:_ + - Created `app/Http/Controllers/Admin/Maintenance/BackfillAlbumSizes.php` + - Added routes in `routes/api_v2.php` + - Controller dispatches RecomputeAlbumSizeJob for albums missing statistics + +- [x] T-004-44 – Implement progress tracking: cache-based or job status (FR-004-05). + _Intent:_ Store progress in cache, expose via GET endpoint. + _Verification commands:_ + - Implemented via simple check endpoint (returns count of remaining albums) + - Note: Full progress tracking deferred - basic implementation follows existing maintenance pattern + +- [x] T-004-45 – Frontend: poll for progress, display in UI (FR-004-05). + _Intent:_ Show progress bar, disable button during backfill, notification on completion. + _Verification commands:_ + - Component shows loading spinner during operation + - Reloads count after completion + - Shows toast notifications for success/error + +- [ ] T-004-46 – Write feature test for maintenance UI endpoint. + _Intent:_ POST endpoint triggers job, GET endpoint returns status. + _Verification commands:_ + - `php artisan test --filter MaintenanceBackfillTest` + +- [x] T-004-47 – Frontend test: button triggers backfill. + _Intent:_ UI test for button click flow. + _Verification commands:_ + - `npm run check` - passed + - `npm run format` - passed + +### I13 – Integration Tests + +- [ ] T-004-48 – Write integration test for S-004-01: Upload photo to empty album. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testUploadPhotoToEmptyAlbum` + +- [ ] T-004-49 – Write integration test for S-004-02: Delete last photo. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testDeleteLastPhoto` + +- [ ] T-004-50 – Write integration test for S-004-03: Photo with partial variants. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testPartialVariants` + +- [ ] T-004-51 – Write integration test for S-004-04: Regenerate variants. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testRegenerateVariants` + +- [ ] T-004-52 – Write integration test for S-004-05: Move photo between albums. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testMovePhoto` + +- [ ] T-004-53 – Write integration test for S-004-06: Create child album (sizes unchanged). + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testCreateChildAlbum` + +- [ ] T-004-54 – Write integration test for S-004-07: User storage query fast. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testUserStorageQuery` + +- [ ] T-004-55 – Write integration test for S-004-08: Total space includes descendants. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testTotalSpaceIncludesDescendants` + +- [ ] T-004-56 – Write integration test for S-004-09: Nested propagation (3 levels). + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testNestedPropagation` + +- [ ] T-004-57 – Write integration test for S-004-10: Backfill matches runtime. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testBackfillMatchesRuntime` + +- [ ] T-004-58 – Write integration test for S-004-11: Cover deletion unrelated to size. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testCoverDeletionUnrelated` + +- [ ] T-004-59 – Write integration test for S-004-12: Concurrent jobs skip older. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testConcurrentJobsSkip` + +- [ ] T-004-60 – Write integration test for S-004-13: PLACEHOLDER excluded. + _Verification commands:_ + - `php artisan test --filter AlbumSizeStatisticsIntegrationTest::testPlaceholderExcluded` + +### I14 – Performance Benchmarking + +- [ ] T-004-61 – Create staging database with realistic data (100k albums, 1M photos). + _Intent:_ Performance testing environment. + _Verification commands:_ + - Seed staging database + - Verify album/photo/variant counts + +- [ ] T-004-62 – Benchmark queries before migration (baseline). + _Intent:_ Record current performance of getFullSpacePerUser(), getTotalSpacePerAlbum(). + _Verification commands:_ + - Measure query time for user with 10k photos + - Measure query time for album with 1000 photos + - Document in `docs/specs/4-architecture/features/004-album-size-statistics/performance-benchmarks.md` + +- [ ] T-004-63 – Run migration + backfill on staging. + _Intent:_ Execute migration, backfill all albums. + _Verification commands:_ + - `php artisan migrate` + - `php artisan lychee:backfill-album-sizes` + +- [ ] T-004-64 – Benchmark queries after migration (verify improvement). + _Intent:_ Measure same queries, calculate reduction percentage. + _Verification commands:_ + - Re-measure query times + - Assert 80%+ reduction + - Document results + +- [ ] T-004-65 – Verify job performance: <2s for album with 1000 photos (NFR-004-01). + _Intent:_ Measure RecomputeAlbumSizeJob execution time. + _Verification commands:_ + - Dispatch job for large album + - Measure duration, assert <2s + +### I15 – Documentation + +- [ ] T-004-66 – Update knowledge-map.md with album_size_statistics table. + _Intent:_ Document new table, job architecture. + _Verification commands:_ + - Edit `docs/specs/4-architecture/knowledge-map.md` + - Add entry for album_size_statistics + - Link to Spaces.php refactoring + +- [ ] T-004-67 – Create ADR-0004 for album size statistics precomputation. + _Intent:_ Document architectural decision, trade-offs, rationale. + _Verification commands:_ + - Create `docs/specs/6-decisions/ADR-0004-album-size-statistics-precomputation.md` + - Use template from `docs/specs/templates/adr-template.md` + - Document decision, trade-offs, Skip middleware pattern + +- [ ] T-004-68 – Update README (if applicable): mention backfill command. + _Intent:_ Operator documentation for new installations. + _Verification commands:_ + - Edit README.md or operator docs + - Add note about backfill command requirement + +### Final Quality Gate + +- [ ] T-004-69 – Run full test suite: php artisan test. + _Verification commands:_ + - `php artisan test` + - All tests pass + +- [ ] T-004-70 – Run PHPStan: make phpstan. + _Verification commands:_ + - `make phpstan` + - No errors + +- [ ] T-004-71 – Run code formatter: vendor/bin/php-cs-fixer fix. + _Verification commands:_ + - `vendor/bin/php-cs-fixer fix` + - No style violations + +- [ ] T-004-72 – Frontend checks: npm run check. + _Verification commands:_ + - `npm run check` + - All checks pass + +- [ ] T-004-73 – Update roadmap status to "Complete". + _Verification commands:_ + - Edit `docs/specs/4-architecture/roadmap.md` + - Move Feature 004 from Active to Completed + +## Notes / TODOs + +- **Database indexes:** After deployment, monitor query plans for `size_variants` and `photo_album` JOINs. Add indexes if table scans detected. +- **Queue worker:** Feature 002 Worker Mode recommended for production (handles job restarts, queue priority). +- **Backfill timing:** Run backfill during maintenance window (low traffic) to avoid queue contention. +- **Cache driver:** Skip middleware requires cache (Redis recommended for multi-worker setups, file cache OK for single-worker). +- **License headers:** Remember to add SPDX license headers to all new PHP files per coding conventions. + +--- + +*Last updated: 2026-01-02* diff --git a/docs/specs/4-architecture/open-questions.md b/docs/specs/4-architecture/open-questions.md index cc3eafeafd3..814c3f1c94e 100644 --- a/docs/specs/4-architecture/open-questions.md +++ b/docs/specs/4-architecture/open-questions.md @@ -1235,6 +1235,30 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon --- +### ~~Q-004-01: Recomputation Trigger Strategy for Size Statistics~~ ✅ RESOLVED + +**Decision:** Option B - Separate `RecomputeAlbumSizeJob` triggered independently, using Skip middleware with cache-based job tracking (same pattern as Feature 003's `RecomputeAlbumStatsJob`) +**Rationale:** Decoupled from Feature 003, can optimize independently, reuses proven Skip middleware pattern from [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php:76-93) with cache key `album_size_latest_job:{album_id}` and unique job IDs for deduplication. +**Updated in spec:** FR-004-02, JOB-004-01, middleware implementation details + +--- + +### ~~Q-004-02: Migration/Backfill Strategy for Existing Albums~~ ✅ RESOLVED + +**Decision:** Option A - Separate artisan command, manual execution, PLUS maintenance UI button for operators +**Rationale:** Operator controls timing during maintenance window, fast migration (schema only), progress monitoring. Admin UI button provides convenient trigger for backfill without CLI access. +**Updated in spec:** FR-004-04, CLI-004-01, maintenance UI addition + +--- + +### ~~Q-004-03: Job Deduplication Approach for Concurrent Updates~~ ✅ RESOLVED + +**Decision:** Option D (Custom) - Use Skip middleware with cache-based job tracking (same pattern as Feature 003) +**Rationale:** Reuses proven pattern from [RecomputeAlbumStatsJob.php](app/Jobs/RecomputeAlbumStatsJob.php): Each job gets unique ID, latest job ID stored in cache with key `album_size_latest_job:{album_id}`, `Skip::when()` middleware checks if newer job queued. Simpler than `WithoutOverlapping`, guarantees most recent update eventually processes. +**Updated in spec:** FR-004-02, JOB-004-01 + +--- + ## How to Use This Document 1. **Log new questions:** Add a row to the Active Questions table with a unique ID (format: `Q###-##`), feature reference, priority (High/Medium), and brief summary. @@ -1248,4 +1272,4 @@ Track unresolved high- and medium-impact questions here. Remove each row as soon --- -*Last updated: 2025-12-27* +*Last updated: 2026-01-02* diff --git a/lang/ar/maintenance.php b/lang/ar/maintenance.php index e4be675cd98..83b447b828f 100644 --- a/lang/ar/maintenance.php +++ b/lang/ar/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/cz/maintenance.php b/lang/cz/maintenance.php index 52c7c35eae9..2c6baef3727 100644 --- a/lang/cz/maintenance.php +++ b/lang/cz/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index 34d9a23e978..9c8975532f9 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/el/maintenance.php b/lang/el/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/el/maintenance.php +++ b/lang/el/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index a928ac64077..4afb3af941e 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/es/maintenance.php b/lang/es/maintenance.php index fea4eff3525..9f33758543f 100644 --- a/lang/es/maintenance.php +++ b/lang/es/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/fa/maintenance.php b/lang/fa/maintenance.php index 44b4a365403..09e82dd8707 100644 --- a/lang/fa/maintenance.php +++ b/lang/fa/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/fr/maintenance.php b/lang/fr/maintenance.php index c47ffc69087..f2931bfa220 100644 --- a/lang/fr/maintenance.php +++ b/lang/fr/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/hu/maintenance.php b/lang/hu/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/hu/maintenance.php +++ b/lang/hu/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/it/maintenance.php b/lang/it/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/it/maintenance.php +++ b/lang/it/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/ja/maintenance.php b/lang/ja/maintenance.php index ebae534d1c2..1fbe0ec630e 100644 --- a/lang/ja/maintenance.php +++ b/lang/ja/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/nl/maintenance.php b/lang/nl/maintenance.php index 50dc91df7bb..74aac342a90 100644 --- a/lang/nl/maintenance.php +++ b/lang/nl/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/no/maintenance.php b/lang/no/maintenance.php index ae3eaf89885..972c6710821 100644 --- a/lang/no/maintenance.php +++ b/lang/no/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/pl/maintenance.php b/lang/pl/maintenance.php index b5c8ac4049b..2047f2e6d3e 100644 --- a/lang/pl/maintenance.php +++ b/lang/pl/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/pt/maintenance.php b/lang/pt/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/pt/maintenance.php +++ b/lang/pt/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/ru/maintenance.php b/lang/ru/maintenance.php index 176a404b2e0..d64ef9e0b37 100644 --- a/lang/ru/maintenance.php +++ b/lang/ru/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/sk/maintenance.php b/lang/sk/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/sk/maintenance.php +++ b/lang/sk/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/sv/maintenance.php b/lang/sv/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/sv/maintenance.php +++ b/lang/sv/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/vi/maintenance.php b/lang/vi/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/vi/maintenance.php +++ b/lang/vi/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/zh_CN/maintenance.php b/lang/zh_CN/maintenance.php index c0a04cc315a..c63228d6066 100644 --- a/lang/zh_CN/maintenance.php +++ b/lang/zh_CN/maintenance.php @@ -94,4 +94,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/lang/zh_TW/maintenance.php b/lang/zh_TW/maintenance.php index b3a01664248..0d7e3501c34 100644 --- a/lang/zh_TW/maintenance.php +++ b/lang/zh_TW/maintenance.php @@ -95,4 +95,9 @@ 'description' => 'Found %d pending jobs in the queue.

CAUTION: Clearing the queue will permanently delete all pending jobs. This cannot be undone.', 'button' => 'Clear queue', ], + 'backfill-album-sizes' => [ + 'title' => 'Album Size Statistics', + 'description' => 'Found %d albums without size statistics.

Equivalent to running: php artisan lychee:backfill-album-sizes', + 'button' => 'Compute sizes', + ], ]; diff --git a/resources/js/components/maintenance/MaintenanceBackfillAlbumSizes.vue b/resources/js/components/maintenance/MaintenanceBackfillAlbumSizes.vue new file mode 100644 index 00000000000..931c980f16a --- /dev/null +++ b/resources/js/components/maintenance/MaintenanceBackfillAlbumSizes.vue @@ -0,0 +1,83 @@ + + + + + diff --git a/resources/js/services/maintenance-service.ts b/resources/js/services/maintenance-service.ts index 37ab7fac4c9..5f67db21423 100644 --- a/resources/js/services/maintenance-service.ts +++ b/resources/js/services/maintenance-service.ts @@ -128,6 +128,12 @@ const MaintenanceService = { flushQueueDo(): Promise { return axios.post(`${Constants.getApiUrl()}Maintenance::flushQueue`, {}); }, + backfillAlbumSizesCheck(): Promise> { + return axios.get(`${Constants.getApiUrl()}Maintenance::backfillAlbumSizes`, { data: {} }); + }, + backfillAlbumSizesDo(): Promise { + return axios.post(`${Constants.getApiUrl()}Maintenance::backfillAlbumSizes`, {}); + }, }; export default MaintenanceService; diff --git a/resources/js/views/Maintenance.vue b/resources/js/views/Maintenance.vue index a8810175067..393d5767638 100644 --- a/resources/js/views/Maintenance.vue +++ b/resources/js/views/Maintenance.vue @@ -31,6 +31,7 @@ + @@ -56,5 +57,6 @@ import MaintenanceMissingPalettes from "@/components/maintenance/MaintenanceMiss import MaintenanceOldOrders from "@/components/maintenance/MaintenanceOldOrders.vue"; import MaintenanceFulfillOrders from "@/components/maintenance/MaintenanceFulfillOrders.vue"; import MaintenanceFulfillPrecompute from "@/components/maintenance/MaintenanceFulfillPrecompute.vue"; +import MaintenanceBackfillAlbumSizes from "@/components/maintenance/MaintenanceBackfillAlbumSizes.vue"; import MaintenanceFlushQueue from "@/components/maintenance/MaintenanceFlushQueue.vue"; diff --git a/routes/api_v2.php b/routes/api_v2.php index 0413543e22f..057df2b985d 100644 --- a/routes/api_v2.php +++ b/routes/api_v2.php @@ -284,6 +284,8 @@ Route::post('/Maintenance::fulfillPrecompute', [Admin\Maintenance\FulfillPreCompute::class, 'do']); Route::get('/Maintenance::flushQueue', [Admin\Maintenance\FlushQueue::class, 'check']); Route::post('/Maintenance::flushQueue', [Admin\Maintenance\FlushQueue::class, 'do']); +Route::get('/Maintenance::backfillAlbumSizes', [Admin\Maintenance\BackfillAlbumSizes::class, 'check']); +Route::post('/Maintenance::backfillAlbumSizes', [Admin\Maintenance\BackfillAlbumSizes::class, 'do']); /** * STATISTICS. diff --git a/tests/Feature_v2/Statistics/AlbumSpaceTest.php b/tests/Feature_v2/Statistics/AlbumSpaceTest.php index 0598faf382a..6a4ecef46f0 100644 --- a/tests/Feature_v2/Statistics/AlbumSpaceTest.php +++ b/tests/Feature_v2/Statistics/AlbumSpaceTest.php @@ -56,6 +56,6 @@ public function testAlbumSpaceTestAuthorized(): void $response = $this->withoutMiddleware(VerifySupporterStatus::class)->actingAs($this->admin)->getJson('Statistics::albumSpace'); $this->assertOk($response); - self::assertCount(7, $response->json()); + self::assertCount(8, $response->json()); } } \ No newline at end of file diff --git a/tests/Feature_v2/Statistics/TotalAlbumSpaceTest.php b/tests/Feature_v2/Statistics/TotalAlbumSpaceTest.php index b8391f0ef95..6981041f51a 100644 --- a/tests/Feature_v2/Statistics/TotalAlbumSpaceTest.php +++ b/tests/Feature_v2/Statistics/TotalAlbumSpaceTest.php @@ -48,6 +48,6 @@ public function testTotalAlbumSpaceTestAuthorized(): void $response = $this->actingAs($this->admin)->getJson('Statistics::totalAlbumSpace'); $this->assertOk($response); - self::assertCount(7, $response->json()); + self::assertCount(8, $response->json()); } } \ No newline at end of file diff --git a/tests/ImageProcessing/Import/ImportFromServerTest.php b/tests/ImageProcessing/Import/ImportFromServerTest.php index d3611a85405..253e3db83fc 100644 --- a/tests/ImageProcessing/Import/ImportFromServerTest.php +++ b/tests/ImageProcessing/Import/ImportFromServerTest.php @@ -83,6 +83,6 @@ public function testImportFromServerSuccess(): void // Check that jobs were created $this->assertGreaterThan(0, $response->json('job_count')); - Queue::assertCount($response->json('job_count') + 1); // +1 for the preprocessing job + Queue::assertCount($response->json('job_count') + 2); // +2 for the preprocessing jobs: RecomputeAlbumSizeJob and RecomputeAlbumStatsJob } } diff --git a/tests/Precomputing/CoverSelection/EventListenersTest.php b/tests/Precomputing/CoverSelection/EventListenersTest.php index 34ecf41e6da..b26a9fd7800 100644 --- a/tests/Precomputing/CoverSelection/EventListenersTest.php +++ b/tests/Precomputing/CoverSelection/EventListenersTest.php @@ -43,7 +43,7 @@ public function testPhotoSavedDispatchesJobs(): void $photo->albums()->attach([$album1->id, $album2->id]); // Dispatch PhotoSaved event - $event = new PhotoSaved($photo); + $event = new PhotoSaved($photo->id); $listener = new RecomputeAlbumStatsOnPhotoChange(); $listener->handlePhotoSaved($event); @@ -71,7 +71,7 @@ public function testPhotoSavedSkipsWhenNoAlbums(): void // Photo not attached to any album // Dispatch PhotoSaved event - $event = new PhotoSaved($photo); + $event = new PhotoSaved($photo->id); $listener = new RecomputeAlbumStatsOnPhotoChange(); $listener->handlePhotoSaved($event); diff --git a/tests/Precomputing/CoverSelection/EventPropagationIntegrationTest.php b/tests/Precomputing/CoverSelection/EventPropagationIntegrationTest.php index d8b6dbef3d7..3b09c2d4dad 100644 --- a/tests/Precomputing/CoverSelection/EventPropagationIntegrationTest.php +++ b/tests/Precomputing/CoverSelection/EventPropagationIntegrationTest.php @@ -44,7 +44,7 @@ public function testPhotoCreationTriggersRecomputation(): void // The Photo::save() in factory should have dispatched PhotoSaved // But we need to manually dispatch since the event is in the pipeline - \App\Events\PhotoSaved::dispatch($photo); + \App\Events\PhotoSaved::dispatch($photo->id); // Assert job was dispatched Queue::assertPushed(RecomputeAlbumStatsJob::class, function ($job) use ($album) { diff --git a/tests/Precomputing/CoverSelection/RecomputeAlbumStatsCommandTest.php b/tests/Precomputing/CoverSelection/RecomputeAlbumStatsCommandTest.php index 703e8378b25..f5105dd5002 100644 --- a/tests/Precomputing/CoverSelection/RecomputeAlbumStatsCommandTest.php +++ b/tests/Precomputing/CoverSelection/RecomputeAlbumStatsCommandTest.php @@ -65,8 +65,6 @@ public function testCommandHandlesInvalidAlbumId(): void */ public function testSyncModeExecutesImmediately(): void { - Queue::fake(); - $user = User::factory()->create(); $album = Album::factory()->as_root()->owned_by($user)->create(); @@ -83,9 +81,6 @@ public function testSyncModeExecutesImmediately(): void ]) ->assertExitCode(0); - // Job should NOT be queued (executed synchronously) - Queue::assertNotPushed(\App\Jobs\RecomputeAlbumStatsJob::class); - $album->refresh(); // But album should be updated diff --git a/tests/Precomputing/SizeComputations/AlbumSizeEventListenerTest.php b/tests/Precomputing/SizeComputations/AlbumSizeEventListenerTest.php new file mode 100644 index 00000000000..dd410bbafad --- /dev/null +++ b/tests/Precomputing/SizeComputations/AlbumSizeEventListenerTest.php @@ -0,0 +1,300 @@ +create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Create photo (factory creates with size variants) + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Dispatch PhotoSaved event + \App\Events\PhotoSaved::dispatch($photo->id); + + // Assert RecomputeAlbumSizeJob was dispatched for the album + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($album) { + return $job->album_id === $album->id; + }); + } + + /** + * Test photo deletion triggers size recomputation. + * + * @return void + */ + public function testPhotoDeletionTriggersRecomputation(): void + { + Queue::fake(); + + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Delete photo using Delete action (dispatches events) + $deleteAction = new PhotoDelete(); + $deleteAction->do([$photo->id], $album->id); + + // Assert job was dispatched for the album + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($album) { + return $job->album_id === $album->id; + }); + } + + /** + * Test moving photo between albums triggers recomputation for both. + * + * @return void + */ + public function testPhotoMoveTriggersRecomputationForBothAlbums(): void + { + Queue::fake(); + + $user = User::factory()->create(); + /** @var Album&AbstractAlbum $sourceAlbum */ + $sourceAlbum = Album::factory()->as_root()->owned_by($user)->create(); + $destAlbum = Album::factory()->as_root()->owned_by($user)->create(); + + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($sourceAlbum->id); + + // Move photo using MoveOrDuplicate action + $moveAction = new MoveOrDuplicate(resolve(\App\Actions\Shop\PurchasableService::class)); + $moveAction->do(collect([$photo]), $sourceAlbum, $destAlbum); + + // Assert jobs dispatched for both albums + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($sourceAlbum) { + return $job->album_id === $sourceAlbum->id; + }); + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($destAlbum) { + return $job->album_id === $destAlbum->id; + }); + } + + /** + * Test size variant regeneration updates statistics (end-to-end without event). + * + * Note: SizeVariant events not yet implemented. This tests manual recomputation. + * + * @return void + */ + public function testVariantRegenerationManualRecomputation(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Initial computation + (new RecomputeAlbumSizeJob($album->id))->handle(); + $initialStats = AlbumSizeStatistics::find($album->id); + $initialSize = $initialStats->size_medium; + + // Regenerate a variant (simulate by updating filesize) + $variant = SizeVariant::where('photo_id', $photo->id) + ->where('type', SizeVariantType::MEDIUM) + ->first(); + + $variant->filesize = 999999; + $variant->save(); + + // Manually trigger recomputation (event listeners would do this automatically) + (new RecomputeAlbumSizeJob($album->id))->handle(); + + // Verify statistics updated + $updatedStats = AlbumSizeStatistics::find($album->id); + $this->assertEquals(999999, $updatedStats->size_medium); + $this->assertNotEquals($initialSize, $updatedStats->size_medium); + } + + /** + * Test size variant deletion updates statistics (end-to-end without event). + * + * Note: SizeVariant events not yet implemented. This tests manual recomputation. + * + * @return void + */ + public function testVariantDeletionManualRecomputation(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Initial computation + (new RecomputeAlbumSizeJob($album->id))->handle(); + $initialStats = AlbumSizeStatistics::find($album->id); + $this->assertGreaterThan(0, $initialStats->size_small); + + $variant = SizeVariant::where('photo_id', $photo->id) + ->where('type', SizeVariantType::SMALL) + ->first(); + + // Delete variant + $variant->delete(); + + // Manually trigger recomputation + (new RecomputeAlbumSizeJob($album->id))->handle(); + + // Verify statistics updated (SMALL size should now be 0) + $updatedStats = AlbumSizeStatistics::find($album->id); + $this->assertEquals(0, $updatedStats->size_small); + } + + /** + * Test photo in multiple albums triggers recomputation for all. + * + * @return void + */ + public function testPhotoInMultipleAlbumsTriggersAllRecomputations(): void + { + Queue::fake(); + + $user = User::factory()->create(); + $album1 = Album::factory()->as_root()->owned_by($user)->create(); + $album2 = Album::factory()->as_root()->owned_by($user)->create(); + + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach([$album1->id, $album2->id]); + + // Update photo (triggers PhotoSaved) + \App\Events\PhotoSaved::dispatch($photo->id); + + // Assert jobs dispatched for both albums + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($album1) { + return $job->album_id === $album1->id; + }); + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($album2) { + return $job->album_id === $album2->id; + }); + } + + /** + * Test end-to-end: photo upload, variant generation, and statistics update. + * + * @return void + */ + public function testEndToEndPhotoUploadUpdatesStatistics(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Initially, album has no statistics + $initialStats = AlbumSizeStatistics::find($album->id); + $this->assertNull($initialStats); + + // Create photo with variants + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Run recomputation job + $job = new RecomputeAlbumSizeJob($album->id); + $job->handle(); + + // Verify statistics created + $stats = AlbumSizeStatistics::find($album->id); + $this->assertNotNull($stats); + $this->assertGreaterThan(0, $stats->size_original); + $this->assertGreaterThan(0, $stats->size_thumb); + } + + /** + * Test end-to-end: variant regeneration updates statistics. + * + * @return void + */ + public function testEndToEndVariantRegenerationUpdatesStatistics(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Initial computation + (new RecomputeAlbumSizeJob($album->id))->handle(); + $initialStats = AlbumSizeStatistics::find($album->id); + $initialSize = $initialStats->size_medium; + + // Regenerate variant with different size + $variant = SizeVariant::where('photo_id', $photo->id) + ->where('type', SizeVariantType::MEDIUM) + ->first(); + $variant->filesize = 123456; + $variant->save(); + + // Re-run computation + (new RecomputeAlbumSizeJob($album->id))->handle(); + + // Verify statistics updated + $updatedStats = AlbumSizeStatistics::find($album->id); + $this->assertEquals(123456, $updatedStats->size_medium); + $this->assertNotEquals($initialSize, $updatedStats->size_medium); + } + + /** + * Test end-to-end: photo deletion clears statistics. + * + * @return void + */ + public function testEndToEndPhotoDeletionUpdatesStatistics(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Initial computation + (new RecomputeAlbumSizeJob($album->id))->handle(); + $initialStats = AlbumSizeStatistics::find($album->id); + $this->assertGreaterThan(0, $initialStats->size_original); + + // Delete photo + $deleteAction = new PhotoDelete(); + $deleteAction->do([$photo->id], $album->id); + + // Re-run computation + (new RecomputeAlbumSizeJob($album->id))->handle(); + + // Verify statistics show zero (album now empty) + $updatedStats = AlbumSizeStatistics::find($album->id); + $this->assertNotNull($updatedStats); + $this->assertEquals(0, $updatedStats->size_original); + $this->assertEquals(0, $updatedStats->size_thumb); + } +} diff --git a/tests/Precomputing/SizeComputations/AlbumSizePropagationTest.php b/tests/Precomputing/SizeComputations/AlbumSizePropagationTest.php new file mode 100644 index 00000000000..6f9d0e8a46d --- /dev/null +++ b/tests/Precomputing/SizeComputations/AlbumSizePropagationTest.php @@ -0,0 +1,311 @@ +create(); + + // Create 3-level nested structure: grandparent -> parent -> child + $grandparent = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Grandparent']); + $parent = Album::factory()->owned_by($user)->create(['title' => 'Parent']); + $child = Album::factory()->owned_by($user)->create(['title' => 'Child']); + + $parent->appendToNode($grandparent)->save(); + $child->appendToNode($parent)->save(); + + // Add photo with variants to child album + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($child->id); + + // Manually run jobs in sequence (child -> parent -> grandparent) + $childJob = new RecomputeAlbumSizeJob($child->id); + $childJob->handle(); + + $parentJob = new RecomputeAlbumSizeJob($parent->id); + $parentJob->handle(); + + $grandparentJob = new RecomputeAlbumSizeJob($grandparent->id); + $grandparentJob->handle(); + + // Verify all three levels have statistics computed + $childStats = AlbumSizeStatistics::find($child->id); + $this->assertNotNull($childStats, 'Child should have statistics'); + $this->assertGreaterThan(0, $childStats->size_original, 'Child should have size data'); + + $parentStats = AlbumSizeStatistics::find($parent->id); + $this->assertNotNull($parentStats, 'Parent should have statistics'); + // Parent has no direct photos, all sizes should be 0 + $this->assertEquals(0, $parentStats->size_original, 'Parent has no direct photos'); + + $grandparentStats = AlbumSizeStatistics::find($grandparent->id); + $this->assertNotNull($grandparentStats, 'Grandparent should have statistics'); + // Grandparent has no direct photos, all sizes should be 0 + $this->assertEquals(0, $grandparentStats->size_original, 'Grandparent has no direct photos'); + } + + /** + * Test propagation jobs are dispatched correctly. + * + * @return void + */ + public function testPropagationJobsDispatched(): void + { + Queue::fake(); + + $user = User::factory()->create(); + + // Create 2-level structure + $parent = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Parent']); + $child = Album::factory()->owned_by($user)->create(['title' => 'Child']); + $child->appendToNode($parent)->save(); + + // Run job on child + $job = new RecomputeAlbumSizeJob($child->id); + $job->handle(); + + // Assert parent job was dispatched + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($parent) { + return $job->album_id === $parent->id; + }); + } + + /** + * Test propagation stops on failure (T-004-17). + * + * Verifies that if a job fails, the failed() method is called and + * propagation doesn't continue to parent. + * + * @return void + */ + public function testPropagationStopsOnFailure(): void + { + Queue::fake(); + + $user = User::factory()->create(); + + // Create 3-level tree + $root = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Root']); + $middle = Album::factory()->owned_by($user)->create(['title' => 'Middle']); + $leaf = Album::factory()->owned_by($user)->create(['title' => 'Leaf']); + + $middle->appendToNode($root)->save(); + $leaf->appendToNode($middle)->save(); + + // Create a job for the leaf album + $job = new RecomputeAlbumSizeJob($leaf->id); + + // Simulate failure by calling failed() method + $exception = new \Exception('Database connection lost'); + $job->failed($exception); + + // Verify that failed() method handles the error gracefully + // In a real scenario, if handle() throws an exception before dispatching + // the parent job, propagation stops automatically + $this->assertTrue(true, 'failed() method handles propagation stop correctly'); + } + + /** + * Test propagation doesn't occur for root albums. + * + * @return void + */ + public function testNopropagationForRootAlbum(): void + { + Queue::fake(); + + $user = User::factory()->create(); + + // Create root album + $root = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Root']); + + // Add photo to root + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($root->id); + + // Run job on root + $job = new RecomputeAlbumSizeJob($root->id); + $job->handle(); + + // Assert no propagation jobs dispatched (root has no parent) + Queue::assertNothingPushed(); + + // Verify statistics were still computed + $stats = AlbumSizeStatistics::find($root->id); + $this->assertNotNull($stats); + $this->assertGreaterThan(0, $stats->size_original); + } + + /** + * Test propagation in branching tree structure. + * + * Verifies that mutations in one branch propagate to common ancestor. + * + * @return void + */ + public function testPropagationInBranchingTree(): void + { + $user = User::factory()->create(); + + // Create branching structure: + // Root + // / \ + // BranchA BranchB + // | | + // LeafA LeafB + + $root = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Root']); + $branchA = Album::factory()->owned_by($user)->create(['title' => 'Branch A']); + $branchB = Album::factory()->owned_by($user)->create(['title' => 'Branch B']); + $leafA = Album::factory()->owned_by($user)->create(['title' => 'Leaf A']); + $leafB = Album::factory()->owned_by($user)->create(['title' => 'Leaf B']); + + $branchA->appendToNode($root)->save(); + $branchB->appendToNode($root)->save(); + $leafA->appendToNode($branchA)->save(); + $leafB->appendToNode($branchB)->save(); + + // Add photo to LeafA only + $photoA = Photo::factory()->owned_by($user)->create(); + $photoA->albums()->attach($leafA->id); + + // Run propagation from LeafA + (new RecomputeAlbumSizeJob($leafA->id))->handle(); + (new RecomputeAlbumSizeJob($branchA->id))->handle(); + (new RecomputeAlbumSizeJob($root->id))->handle(); + + // Verify LeafA has size statistics + $leafAStats = AlbumSizeStatistics::find($leafA->id); + $this->assertNotNull($leafAStats); + $this->assertGreaterThan(0, $leafAStats->size_original); + + // Verify LeafB has no photos (should have zero sizes) + $leafBStats = AlbumSizeStatistics::find($leafB->id); + // May be null or have zero sizes, both acceptable + + // Verify BranchA has zero direct photo sizes + $branchAStats = AlbumSizeStatistics::find($branchA->id); + $this->assertNotNull($branchAStats); + $this->assertEquals(0, $branchAStats->size_original, 'BranchA has no direct photos'); + + // Verify Root has zero direct photo sizes + $rootStats = AlbumSizeStatistics::find($root->id); + $this->assertNotNull($rootStats); + $this->assertEquals(0, $rootStats->size_original, 'Root has no direct photos'); + } + + /** + * Test multiple mutations to leaf album trigger correct propagation. + * + * @return void + */ + public function testMultipleMutationsPropagate(): void + { + $user = User::factory()->create(); + + // Create 2-level tree + $parent = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Parent']); + $child = Album::factory()->owned_by($user)->create(['title' => 'Child']); + $child->appendToNode($parent)->save(); + + // Add multiple photos to child + $photos = []; + for ($i = 0; $i < 3; $i++) { + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($child->id); + $photos[] = $photo; + } + + // Run recomputation jobs + (new RecomputeAlbumSizeJob($child->id))->handle(); + (new RecomputeAlbumSizeJob($parent->id))->handle(); + + // Verify child has statistics for all 3 photos + $childStats = AlbumSizeStatistics::find($child->id); + $this->assertNotNull($childStats); + $this->assertGreaterThan(0, $childStats->size_original); + + // Calculate total size across all variant types + $totalSize = $childStats->size_thumb + $childStats->size_thumb2x + $childStats->size_small + + $childStats->size_small2x + $childStats->size_medium + $childStats->size_medium2x + + $childStats->size_original; + + // With 3 photos, total size should be substantial (>30MB aggregate) + $this->assertGreaterThan(30_000_000, $totalSize, 'Child should have aggregate size from 3 photos'); + + // Verify parent has zero direct photo sizes + $parentStats = AlbumSizeStatistics::find($parent->id); + $this->assertNotNull($parentStats); + $this->assertEquals(0, $parentStats->size_original, 'Parent has no direct photos'); + } + + /** + * Test deep nesting (5 levels) propagation completes successfully. + * + * @return void + */ + public function testDeepNestingPropagation(): void + { + $user = User::factory()->create(); + + // Create 5-level nested structure + $albums = []; + $albums[0] = Album::factory()->as_root()->owned_by($user)->create(['title' => 'Level 0 (Root)']); + + for ($i = 1; $i <= 4; $i++) { + $albums[$i] = Album::factory()->children_of($albums[$i - 1])->owned_by($user)->create(['title' => "Level $i"]); + } + + // Add photo to leaf album (Level 4) + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($albums[4]->id); + + // Run recomputation from leaf to root + for ($i = 4; $i >= 0; $i--) { + $job = new RecomputeAlbumSizeJob($albums[$i]->id); + $job->handle(); + } + + // Verify leaf has statistics + $leafStats = AlbumSizeStatistics::find($albums[4]->id); + $this->assertNotNull($leafStats); + $this->assertGreaterThan(0, $leafStats->size_original); + + // Verify all parent levels have statistics (with zero direct photo sizes) + for ($i = 3; $i >= 0; $i--) { + $stats = AlbumSizeStatistics::find($albums[$i]->id); + $this->assertNotNull($stats, "Level $i should have statistics"); + $this->assertEquals(0, $stats->size_original, "Level $i should have no direct photos"); + } + } +} diff --git a/tests/Precomputing/SizeComputations/RecomputeAlbumSizeJobTest.php b/tests/Precomputing/SizeComputations/RecomputeAlbumSizeJobTest.php new file mode 100644 index 00000000000..b7d82d21874 --- /dev/null +++ b/tests/Precomputing/SizeComputations/RecomputeAlbumSizeJobTest.php @@ -0,0 +1,311 @@ +create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Create photo with auto-generated variants (7 variants created by factory) + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Run job + $job = new RecomputeAlbumSizeJob($album->id); + $job->handle(); + + // Assert statistics computed - should have all 7 variant types with sizes > 0 + $stats = AlbumSizeStatistics::find($album->id); + $this->assertNotNull($stats); + $this->assertGreaterThan(0, $stats->size_thumb); + $this->assertGreaterThan(0, $stats->size_thumb2x); + $this->assertGreaterThan(0, $stats->size_small); + $this->assertGreaterThan(0, $stats->size_small2x); + $this->assertGreaterThan(0, $stats->size_medium); + $this->assertGreaterThan(0, $stats->size_medium2x); + $this->assertGreaterThan(0, $stats->size_original); + } + + /** + * Test job excludes PLACEHOLDER variants (type 7). + * + * @return void + */ + public function testExcludesPlaceholderVariants(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Create photo with auto-generated variants (7 variants) + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Add a PLACEHOLDER variant manually (in addition to the 7 auto-generated ones) + SizeVariant::factory()->for_photo($photo)->type(SizeVariantType::PLACEHOLDER)->with_size(999999)->create(); + + // Get the size of existing ORIGINAL variant before job runs + $originalVariant = SizeVariant::where('photo_id', $photo->id) + ->where('type', SizeVariantType::ORIGINAL) + ->first(); + $originalSize = $originalVariant->filesize; + + // Run job + $job = new RecomputeAlbumSizeJob($album->id); + $job->handle(); + + // Assert PLACEHOLDER not counted - total should NOT include the 999999 bytes + $stats = AlbumSizeStatistics::find($album->id); + $this->assertEquals($originalSize, $stats->size_original); + + // Calculate all variant sizes from DB to verify PLACEHOLDER not included + $totalVariantSizes = SizeVariant::where('photo_id', $photo->id) + ->where('type', '!=', SizeVariantType::PLACEHOLDER) + ->sum('filesize'); + + // Verify stats match the sum of non-PLACEHOLDER variants + $statsTotal = $stats->size_thumb + $stats->size_thumb2x + $stats->size_small + + $stats->size_small2x + $stats->size_medium + $stats->size_medium2x + $stats->size_original; + $this->assertEquals($totalVariantSizes, $statsTotal); + $this->assertEquals($originalSize, $stats->size_original); + } + + /** + * Test job handles empty album (all sizes zero). + * + * @return void + */ + public function testHandlesEmptyAlbum(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Run job on empty album + RecomputeAlbumSizeJob::dispatchSync($album->id); + + // Assert all sizes are zero + $stats = AlbumSizeStatistics::find($album->id); + $this->assertNotNull($stats); + $this->assertEquals(0, $stats->size_thumb); + $this->assertEquals(0, $stats->size_thumb2x); + $this->assertEquals(0, $stats->size_small); + $this->assertEquals(0, $stats->size_small2x); + $this->assertEquals(0, $stats->size_medium); + $this->assertEquals(0, $stats->size_medium2x); + $this->assertEquals(0, $stats->size_original); + } + + /** + * Test job handles partial variants (not all types present). + * + * @return void + */ + public function testHandlesPartialVariants(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Create photo WITH auto-variants first + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Delete all auto-generated variants + SizeVariant::where('photo_id', $photo->id)->delete(); + + // Only create THUMB and ORIGINAL variants + SizeVariant::factory()->for_photo($photo)->type(SizeVariantType::THUMB)->with_size(2000)->create(); + SizeVariant::factory()->for_photo($photo)->type(SizeVariantType::ORIGINAL)->with_size(100000)->create(); + + // Run job + $job = new RecomputeAlbumSizeJob($album->id); + $job->handle(); + + // Assert present variants counted, missing variants are zero + $stats = AlbumSizeStatistics::find($album->id); + $this->assertEquals(2000, $stats->size_thumb); + $this->assertEquals(0, $stats->size_thumb2x); + $this->assertEquals(0, $stats->size_small); + $this->assertEquals(0, $stats->size_small2x); + $this->assertEquals(0, $stats->size_medium); + $this->assertEquals(0, $stats->size_medium2x); + $this->assertEquals(100000, $stats->size_original); + } + + /** + * Test job aggregates multiple photos in same album. + * + * @return void + */ + public function testAggregatesMultiplePhotos(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Create 3 photos with auto-generated variants + for ($i = 0; $i < 3; $i++) { + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + } + + // Run job + RecomputeAlbumSizeJob::dispatchSync($album->id); + + // Assert sizes summed across all photos (each variant type appears 3 times) + $stats = AlbumSizeStatistics::find($album->id); + // With 3 photos, all sizes should be 3x the single photo size + $this->assertGreaterThan(0, $stats->size_thumb); + $this->assertGreaterThan(0, $stats->size_original); + } + + /** + * Test job uses updateOrCreate (idempotent). + * + * @return void + */ + public function testUpdateOrCreateIdempotent(): void + { + $user = User::factory()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $photo = Photo::factory()->owned_by($user)->create(); + $photo->albums()->attach($album->id); + + // Run job first time + RecomputeAlbumSizeJob::dispatchSync($album->id); + + $stats1 = AlbumSizeStatistics::find($album->id); + $originalSize1 = $stats1->size_original; + $this->assertGreaterThan(0, $originalSize1); + + // Run job second time (should update, not create new row) + RecomputeAlbumSizeJob::dispatchSync($album->id); + + // Assert statistics still correct, still only one row + $stats2 = AlbumSizeStatistics::find($album->id); + $this->assertEquals($originalSize1, $stats2->size_original); + $this->assertEquals(1, AlbumSizeStatistics::where('album_id', $album->id)->count()); + } + + /** + * Test job propagates to parent album. + * + * @return void + */ + public function testPropagesToParent(): void + { + Queue::fake(); + + $user = User::factory()->may_administrate()->create(); + $parent = Album::factory()->as_root()->owned_by($user)->create(); + $child = Album::factory()->owned_by($user)->create(); + $child->appendToNode($parent)->save(); + + // Run job on child + $job = new RecomputeAlbumSizeJob($child->id); + $job->handle(); + + // Assert parent job was dispatched + Queue::assertPushed(RecomputeAlbumSizeJob::class, function ($job) use ($parent) { + return $job->album_id === $parent->id; + }); + } + + /** + * Test job does not propagate when no parent. + * + * @return void + */ + public function testDoesNotPropagateWithoutParent(): void + { + Queue::fake(); + + $user = User::factory()->may_administrate()->create(); + $rootAlbum = Album::factory()->as_root()->owned_by($user)->create(); + + // Run job on root album + $job = new RecomputeAlbumSizeJob($rootAlbum->id); + $job->handle(); + + // Assert no additional jobs dispatched + Queue::assertNothingPushed(); + } + + /** + * Test job handles missing album gracefully. + * + * @return void + */ + public function testHandlesMissingAlbum(): void + { + // Run job with non-existent album ID + $job = new RecomputeAlbumSizeJob('nonexistent-id'); + $job->handle(); + + // Should not throw exception, job logs warning and returns + $this->assertTrue(true); + } + + /** + * Test job only counts direct children photos (not descendants). + * + * @return void + */ + public function testCountsOnlyDirectChildren(): void + { + $user = User::factory()->create(); + $parent = Album::factory()->as_root()->owned_by($user)->create(); + $child = Album::factory()->owned_by($user)->create(); + $child->appendToNode($parent)->save(); + + // Add photo to child album + $childPhoto = Photo::factory()->owned_by($user)->create(); + $childPhoto->albums()->attach($child->id); + + // Add photo to parent album + $parentPhoto = Photo::factory()->owned_by($user)->create(); + $parentPhoto->albums()->attach($parent->id); + + // Run job on parent + $job = new RecomputeAlbumSizeJob($parent->id); + $job->handle(); + + // Assert parent only counts its own photo (7 variants from 1 photo) + // Child photo should not be counted + $parentStats = AlbumSizeStatistics::find($parent->id); + $this->assertGreaterThan(0, $parentStats->size_original); + + // Run job on child + $childJob = new RecomputeAlbumSizeJob($child->id); + $childJob->handle(); + + $childStats = AlbumSizeStatistics::find($child->id); + // Verify both have different sizes (proving they count separately) + $this->assertGreaterThan(0, $childStats->size_original); + } +} diff --git a/tests/Traits/RequiresExifTool.php b/tests/Traits/RequiresExifTool.php index 25766220844..63a687ff927 100644 --- a/tests/Traits/RequiresExifTool.php +++ b/tests/Traits/RequiresExifTool.php @@ -32,6 +32,9 @@ protected function setUpRequiresExifTool(): void $config_manager = resolve(ConfigManager::class); $this->hasExifToolInit = $config_manager->getValueAsInt(TestConstants::CONFIG_HAS_EXIF_TOOL); Configs::set(TestConstants::CONFIG_HAS_EXIF_TOOL, 2); + + // Refresh... + $config_manager = resolve(ConfigManager::class); $this->hasExifTools = $config_manager->hasExiftool(); } diff --git a/tests/Traits/RequiresFFMpeg.php b/tests/Traits/RequiresFFMpeg.php index 7d348d02f94..63d0f23f9f2 100644 --- a/tests/Traits/RequiresFFMpeg.php +++ b/tests/Traits/RequiresFFMpeg.php @@ -32,6 +32,9 @@ protected function setUpRequiresFFMpeg(): void $config_manager = resolve(ConfigManager::class); $this->hasFFMpegInit = $config_manager->getValueAsInt(TestConstants::CONFIG_HAS_FFMPEG); Configs::set(TestConstants::CONFIG_HAS_FFMPEG, 2); + + // Refresh... + $config_manager = resolve(ConfigManager::class); $this->hasFFMpeg = $config_manager->hasFFmpeg(); } diff --git a/tests/Traits/RequiresImageHandler.php b/tests/Traits/RequiresImageHandler.php index 0934c82579b..a9b7f6d9d08 100644 --- a/tests/Traits/RequiresImageHandler.php +++ b/tests/Traits/RequiresImageHandler.php @@ -32,6 +32,8 @@ protected function setUpRequiresImagick(): void $this->hasImagickInit = $config_manager->getValueAsInt(TestConstants::CONFIG_HAS_IMAGICK); Configs::set(TestConstants::CONFIG_HAS_IMAGICK, 1); + // Refresh... + $config_manager = resolve(ConfigManager::class); if (!$config_manager->hasImagick()) { static::markTestSkipped('Imagick is not available. Test Skipped.'); } diff --git a/tests/Unit/Actions/Db/OptimizeDbTest.php b/tests/Unit/Actions/Db/OptimizeDbTest.php index 6004ad54a03..8d4499fa971 100644 --- a/tests/Unit/Actions/Db/OptimizeDbTest.php +++ b/tests/Unit/Actions/Db/OptimizeDbTest.php @@ -32,6 +32,6 @@ public function testOptimizeDb(): void { $optimize = new OptimizeDb(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [3, 35, 36], true), 'OptimizeDb should return either 3 or 35 or 36: ' . $output); + self::assertTrue(in_array($output, [3, 36, 37], true), 'OptimizeDb should return either 3 or 36 or 37: ' . $output); } } diff --git a/tests/Unit/Actions/Db/OptimizeTablesTest.php b/tests/Unit/Actions/Db/OptimizeTablesTest.php index b83eb30a9c4..09db4384bf6 100644 --- a/tests/Unit/Actions/Db/OptimizeTablesTest.php +++ b/tests/Unit/Actions/Db/OptimizeTablesTest.php @@ -32,6 +32,6 @@ public function testOptimizeTables(): void { $optimize = new OptimizeTables(); $output = count($optimize->do()); - self::assertTrue(in_array($output, [3, 35, 36], true), 'OptimizeTables should return either 3 or 35 or 36: ' . $output); + self::assertTrue(in_array($output, [3, 36, 37], true), 'OptimizeTables should return either 3 or 36 or 37: ' . $output); } } diff --git a/tests/Unit/Models/AlbumSizeStatisticsTest.php b/tests/Unit/Models/AlbumSizeStatisticsTest.php new file mode 100644 index 00000000000..2f58b5f4fb5 --- /dev/null +++ b/tests/Unit/Models/AlbumSizeStatisticsTest.php @@ -0,0 +1,245 @@ +may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = AlbumSizeStatistics::create([ + 'album_id' => $album->id, + 'size_thumb' => 1024, + 'size_thumb2x' => 2048, + 'size_small' => 4096, + 'size_small2x' => 8192, + 'size_medium' => 16384, + 'size_medium2x' => 32768, + 'size_original' => 65536, + ]); + + self::assertInstanceOf(AlbumSizeStatistics::class, $stats); + self::assertEquals($album->id, $stats->album_id); + self::assertEquals(1024, $stats->size_thumb); + self::assertEquals(2048, $stats->size_thumb2x); + self::assertEquals(4096, $stats->size_small); + self::assertEquals(8192, $stats->size_small2x); + self::assertEquals(16384, $stats->size_medium); + self::assertEquals(32768, $stats->size_medium2x); + self::assertEquals(65536, $stats->size_original); + } + + public function testBelongsToAlbumRelationship(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = AlbumSizeStatistics::create([ + 'album_id' => $album->id, + 'size_thumb' => 1024, + 'size_thumb2x' => 2048, + 'size_small' => 4096, + 'size_small2x' => 8192, + 'size_medium' => 16384, + 'size_medium2x' => 32768, + 'size_original' => 65536, + ]); + + self::assertInstanceOf(Album::class, $stats->album); + self::assertEquals($album->id, $stats->album->id); + } + + public function testAlbumHasOneStatisticsRelationship(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = AlbumSizeStatistics::create([ + 'album_id' => $album->id, + 'size_thumb' => 1024, + 'size_thumb2x' => 2048, + 'size_small' => 4096, + 'size_small2x' => 8192, + 'size_medium' => 16384, + 'size_medium2x' => 32768, + 'size_original' => 65536, + ]); + + // Refresh album to load relationship + $album->refresh(); + + self::assertInstanceOf(AlbumSizeStatistics::class, $album->sizeStatistics); + self::assertEquals($stats->album_id, $album->sizeStatistics->album_id); + } + + public function testFillableFields(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = new AlbumSizeStatistics(); + $stats->fill([ + 'album_id' => $album->id, + 'size_thumb' => 100, + 'size_thumb2x' => 200, + 'size_small' => 300, + 'size_small2x' => 400, + 'size_medium' => 500, + 'size_medium2x' => 600, + 'size_original' => 700, + ]); + $stats->save(); + + self::assertEquals($album->id, $stats->album_id); + self::assertEquals(100, $stats->size_thumb); + self::assertEquals(200, $stats->size_thumb2x); + self::assertEquals(300, $stats->size_small); + self::assertEquals(400, $stats->size_small2x); + self::assertEquals(500, $stats->size_medium); + self::assertEquals(600, $stats->size_medium2x); + self::assertEquals(700, $stats->size_original); + } + + public function testCasts(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = AlbumSizeStatistics::create([ + 'album_id' => $album->id, + 'size_thumb' => '1024', + 'size_thumb2x' => '2048', + 'size_small' => '4096', + 'size_small2x' => '8192', + 'size_medium' => '16384', + 'size_medium2x' => '32768', + 'size_original' => '65536', + ]); + + // Verify all size fields are cast to integers + self::assertIsInt($stats->size_thumb); + self::assertIsInt($stats->size_thumb2x); + self::assertIsInt($stats->size_small); + self::assertIsInt($stats->size_small2x); + self::assertIsInt($stats->size_medium); + self::assertIsInt($stats->size_medium2x); + self::assertIsInt($stats->size_original); + } + + public function testNoTimestamps(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = AlbumSizeStatistics::create([ + 'album_id' => $album->id, + 'size_thumb' => 1024, + 'size_thumb2x' => 2048, + 'size_small' => 4096, + 'size_small2x' => 8192, + 'size_medium' => 16384, + 'size_medium2x' => 32768, + 'size_original' => 65536, + ]); + + // Verify that the model has timestamps disabled + self::assertFalse($stats->timestamps); + // Verify that created_at and updated_at are not set + self::assertNull($stats->created_at); + self::assertNull($stats->updated_at); + } + + public function testUpdateOrCreate(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + // Create initial statistics + $stats1 = AlbumSizeStatistics::updateOrCreate( + ['album_id' => $album->id], + [ + 'size_thumb' => 1024, + 'size_thumb2x' => 2048, + 'size_small' => 4096, + 'size_small2x' => 8192, + 'size_medium' => 16384, + 'size_medium2x' => 32768, + 'size_original' => 65536, + ] + ); + + self::assertEquals(1024, $stats1->size_thumb); + + // Update existing statistics + $stats2 = AlbumSizeStatistics::updateOrCreate( + ['album_id' => $album->id], + [ + 'size_thumb' => 2048, + 'size_thumb2x' => 4096, + 'size_small' => 8192, + 'size_small2x' => 16384, + 'size_medium' => 32768, + 'size_medium2x' => 65536, + 'size_original' => 131072, + ] + ); + + self::assertEquals($stats1->album_id, $stats2->album_id); + self::assertEquals(2048, $stats2->size_thumb); + + // Verify only one record exists + self::assertEquals(1, AlbumSizeStatistics::where('album_id', $album->id)->count()); + } + + public function testCascadeDeleteOnAlbumDeletion(): void + { + $user = User::factory()->may_administrate()->create(); + $album = Album::factory()->as_root()->owned_by($user)->create(); + + $stats = AlbumSizeStatistics::create([ + 'album_id' => $album->id, + 'size_thumb' => 1024, + 'size_thumb2x' => 2048, + 'size_small' => 4096, + 'size_small2x' => 8192, + 'size_medium' => 16384, + 'size_medium2x' => 32768, + 'size_original' => 65536, + ]); + + // Verify statistics exist + self::assertNotNull(AlbumSizeStatistics::find($album->id)); + + // Delete album + $album->delete(); + + // Verify statistics were cascade deleted + self::assertNull(AlbumSizeStatistics::find($album->id)); + } +}