Skip to content

Fix MySQL error 1390 (too many placeholders) in album/photo deletion path#4225

Merged
ildyria merged 7 commits intomasterfrom
copilot/fix-album-deletion-error
Mar 25, 2026
Merged

Fix MySQL error 1390 (too many placeholders) in album/photo deletion path#4225
ildyria merged 7 commits intomasterfrom
copilot/fix-album-deletion-error

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Mar 24, 2026

Deleting albums with large photo counts hits MySQL's 65,535 prepared-statement placeholder limit across multiple whereIn calls that receive unbounded PHP arrays of photo/album IDs.

Root cause

Two patterns:

  1. whereIn('photo_id', $photos_ids) — array can be arbitrarily large (one entry per photo)
  2. whereIn('album_id', $album_ids) — same risk for deeply recursive album trees

Fixes

app/Actions/Album/Delete.php

  • findAllPhotosToDelete(): replace whereIn(PA::PHOTO_ID, $photos_ids) (PHP array round-trip) with a correlated subquery — eliminates the intermediate array entirely

app/DTO/Delete/PhotosToBeDeletedDTO.php — added CHUNK_SIZE = 1000

  • softDelete(): 2-D chunking on both soft_delete_photo_ids (outer) and album_ids (inner)
  • forceDelete(): chunked force_delete_photo_ids across all writes — album header_id/cover_id nullification and five table deletes (size_variants, statistics, palettes, photo_album, photos)
  • collectSizeVariantPathsByPhotoID() / collectLivePhotoPathsByPhotoID(): chunk photo_ids, accumulate with reduce+concat

app/DTO/Delete/AlbumsToBeDeletedDTO.php — added CHUNK_SIZE = 1000

  • Safety count-check fanned out across chunks (contains short-circuits on first hit)
  • Albums deletion pre-sorted globally by _lft DESC (fetched in chunks, sorted in PHP) so children precede parents across chunk boundaries, preserving nested-set FK integrity
  • Deletion order maintained: dependents → albums (leaf-first) → base_albums

💬 Send tasks to Copilot coding agent from Slack and Teams to turn conversations into code. Copilot posts an update in your thread when it's finished.

Summary by CodeRabbit

Refactor

  • Enhanced the database layer for bulk deletion operations to process large datasets more efficiently and reliably. The system now handles scenarios involving many photos and albums without encountering system-level constraints that previously could cause errors during large batch deletions. This improvement ensures greater stability and performance when managing large photo collections and complex album structures.

… with many photos

Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com>
Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/4ddd5192-7894-4390-85ae-d0eaf37842db
Copilot AI changed the title [WIP] Fix SQL error during album deletion with many pictures Fix MySQL "too many placeholders" error when deleting albums with large photo counts Mar 24, 2026
Copilot AI requested a review from ildyria March 24, 2026 21:32
…SQL placeholder limit

Co-authored-by: ildyria <627094+ildyria@users.noreply.github.com>
Agent-Logs-Url: https://github.com/LycheeOrg/Lychee/sessions/07aec537-3849-4561-90cc-10d5fb571821
Copilot AI changed the title Fix MySQL "too many placeholders" error when deleting albums with large photo counts Fix MySQL error 1390 (too many placeholders) in album/photo deletion path Mar 24, 2026
@ildyria ildyria marked this pull request as ready for review March 24, 2026 21:52
@ildyria ildyria requested a review from a team as a code owner March 24, 2026 21:52
@ildyria
Copy link
Copy Markdown
Member

ildyria commented Mar 24, 2026

@coderabbitai review please.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

@ildyria Sure! I'll start reviewing the changes now.


🧠 Learnings used
Learnt from: ildyria
Repo: LycheeOrg/Lychee PR: 3683
File: app/Actions/Shop/OrderService.php:65-73
Timestamp: 2025-09-16T21:56:01.607Z
Learning: The validation for album-photo membership in PurchasableService::getEffectivePurchasableForPhoto() uses a JOIN query with the photo_album pivot table to ensure that only purchasables for albums that actually contain the specified photo are returned, preventing price spoofing attacks where clients could use arbitrary album_ids.

Learnt from: ildyria
Repo: LycheeOrg/Lychee PR: 3922
File: app/Listeners/RecomputeAlbumSizeOnPhotoMutation.php:63-69
Timestamp: 2026-01-03T12:36:40.962Z
Learning: In Lychee, "deleting a photo from an album" (removing the photo-album association) is distinct from "deleting a photo entirely". When PhotoDeleted event is dispatched with a specific album_id, it indicates the photo was removed from that particular album only. The photo may still exist in other albums due to the many-to-many relationship, so only the specified album's size statistics need recomputation.

Learnt from: ildyria
Repo: LycheeOrg/Lychee PR: 3626
File: database/migrations/2025_06_07_144157_photo_tags_to_table.php:87-105
Timestamp: 2025-08-18T10:19:04.946Z
Learning: In the Lychee photo management system, the migration `2025_06_07_144157_photo_tags_to_table.php` runs on data that only contains comma-separated tag names in tag_albums.show_tags - OR/AND expressions do not exist at this migration time, so handling them is unnecessary.

Learnt from: ildyria
Repo: LycheeOrg/Lychee PR: 3683
File: app/Actions/Shop/OrderService.php:65-73
Timestamp: 2025-09-16T21:56:01.607Z
Learning: The validation for album-photo membership in OrderService::addPhotoToOrder() is implemented within PurchasableService::getEffectivePurchasableForPhoto() using a whereHas('albums') constraint that ensures the photo belongs to the specified album_id before applying pricing logic.

Learnt from: ildyria
Repo: LycheeOrg/Lychee PR: 3702
File: database/migrations/2025_08_27_203030_create_purchasable.php:35-39
Timestamp: 2025-09-21T20:09:31.762Z
Learning: In the Lychee codebase, ildyria has decided to ignore the unique constraint issue with UNIQUE(album_id, photo_id) in the purchasables table where NULL values in MySQL/MariaDB don't enforce uniqueness as expected. This is acceptable for their multi-database support strategy (sqlite, pgsql, mariadb) and likely handled at the application level.

Learnt from: ildyria
Repo: LycheeOrg/Lychee PR: 3683
File: app/Actions/Shop/PurchasableService.php:286-296
Timestamp: 2025-09-18T13:37:32.687Z
Learning: The deleteMultipleAlbumPurchasables() method in PurchasableService is designed to delete ALL purchasables (both album-level with photo_id IS NULL and photo-level with photo_id IS NOT NULL) for the specified album_ids. This is the intended behavior when albums are being deleted, as all photos within those albums will also be deleted, making their purchasables obsolete.
✅ Actions performed

Review triggered.

Note: CodeRabbit is an incremental review system and does not re-review already reviewed commits. This command is applicable only when automatic reviews are paused.

@coderabbitai
Copy link
Copy Markdown

coderabbitai bot commented Mar 24, 2026

Important

Review skipped

Bot user detected.

To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8b71bb28-c24a-49c3-91d6-e81ecd65697c

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR refactors delete operations across album and photo DTOs to use chunking and subqueries instead of materializing large ID arrays into memory, preventing MySQL prepared statement placeholder limit errors while maintaining existing deletion order semantics and public interfaces.

Changes

Cohort / File(s) Summary
Query Construction
app/Actions/Album/Delete.php
Replaced array-based whereIn() with subquery-based approach to avoid materializing full photo ID lists and prevent placeholder limit issues.
Deletion Operations with Chunking
app/DTO/Delete/AlbumsToBeDeletedDTO.php, app/DTO/Delete/PhotosToBeDeletedDTO.php
Added CHUNK_SIZE constants and refactored all whereIn() operations to process IDs in batches, including dependent-table deletes, header/cover updates, and multi-table cascades. Preserved deletion order via explicit cross-chunk sorting for albums.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🐰 A rabbit hops through chunks so neat,
No placeholder limits, the query's sweet!
Subqueries dance where arrays once grew,
Deletion flows smooth—batched clean and true! 🌿

🚥 Pre-merge checks | ✅ 1
✅ Passed checks (1 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (3)
app/Actions/Album/Delete.php (2)

120-129: deleteTagAlbums uses unchunked whereIn calls.

The tag album deletion path uses multiple whereIn($tag_album_ids) calls without chunking. While tag albums are typically fewer in number than photos, extremely large bulk deletions could still hit the placeholder limit.

♻️ Optional: Apply consistent chunking pattern
 private function deleteTagAlbums(array $tag_album_ids): void
 {
+    if (count($tag_album_ids) === 0) {
+        return;
+    }
     $purchasable_service = resolve(PurchasableService::class);
     $purchasable_service->deleteMultipleAlbumPurchasables($tag_album_ids);
-    DB::table('live_metrics')->whereIn('album_id', $tag_album_ids)->delete();
-    DB::table(APC::ACCESS_PERMISSIONS)->whereIn(APC::BASE_ALBUM_ID, $tag_album_ids)->delete();
-    DB::table('statistics')->whereIn('album_id', $tag_album_ids)->delete();
-    DB::table('tag_albums')->whereIn('id', $tag_album_ids)->delete();
-    DB::table('base_albums')->whereIn('id', $tag_album_ids)->delete();
+    collect($tag_album_ids)->chunk(1000)->each(function ($chunk): void {
+        DB::table('live_metrics')->whereIn('album_id', $chunk->all())->delete();
+        DB::table(APC::ACCESS_PERMISSIONS)->whereIn(APC::BASE_ALBUM_ID, $chunk->all())->delete();
+        DB::table('statistics')->whereIn('album_id', $chunk->all())->delete();
+        DB::table('tag_albums')->whereIn('id', $chunk->all())->delete();
+        DB::table('base_albums')->whereIn('id', $chunk->all())->delete();
+    });
 }

152-164: Good use of subquery to avoid photo_id placeholder explosion, but consider album_id array size.

The subquery approach elegantly avoids materializing potentially millions of photo IDs. However, whereIn(PA::ALBUM_ID, $album_ids) in both the subquery (line 159) and the initial query (line 144) could theoretically hit the placeholder limit if the recursive album collection in findAllAlbumsToDelete produces a very large array. This is unlikely in practice for most installations, but the code already acknowledges the memory risk with an explicit comment. Consider chunking the album_ids array as defensive programming for deeply nested or exceptionally large album trees.

app/DTO/Delete/PhotosToBeDeletedDTO.php (1)

107-107: exclude_size_variants_ids is unchunked in both fetch and usage.

Line 107 fetches all size_variant_id from order_items into memory, and line 134 uses it in a whereIn without chunking. If the order history grows large, this could hit limits.

However, this is likely acceptable in practice since order items referencing size variants that are about to be deleted would be an unusual edge case (orders typically reference photos that shouldn't be deleted).

Also applies to: 134-134


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: a8889789-cf90-41da-8059-49e5c5d7249e

📥 Commits

Reviewing files that changed from the base of the PR and between 1bf72e7 and da91c83.

📒 Files selected for processing (3)
  • app/Actions/Album/Delete.php
  • app/DTO/Delete/AlbumsToBeDeletedDTO.php
  • app/DTO/Delete/PhotosToBeDeletedDTO.php

@codecov
Copy link
Copy Markdown

codecov bot commented Mar 25, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 90.25%. Comparing base (1bf72e7) to head (4215947).
⚠️ Report is 6 commits behind head on master.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@ildyria ildyria merged commit c3148b4 into master Mar 25, 2026
45 checks passed
@ildyria ildyria deleted the copilot/fix-album-deletion-error branch March 25, 2026 21:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants