Skip to content

fix: normalise rating before CHECK; harden book-club publisher upsert#271

Merged
fabiodalez-dev merged 3 commits into
mainfrom
fix/coderabbit-preexisting-migrate-bookclub
Jul 20, 2026
Merged

fix: normalise rating before CHECK; harden book-club publisher upsert#271
fabiodalez-dev merged 3 commits into
mainfrom
fix/coderabbit-preexisting-migrate-bookclub

Conversation

@fabiodalez-dev

@fabiodalez-dev fabiodalez-dev commented Jul 19, 2026

Copy link
Copy Markdown
Owner

Two pre-existing findings CodeRabbit raised on already-merged work (not on any open PR). Both verified against the current tree.

🗄️ migrate_0.7.31.sql — CHECK add can abort the migration on legacy data

The guarded ALTER TABLE libri ADD CONSTRAINT chk_lt_rating CHECK (rating IS NULL OR rating BETWEEN 1 AND 5) fails and aborts the whole migration if libri already holds an out-of-range rating (e.g. a legacy 0 = "unrated" or a 1–10 import scale). MySQL rejects an enforced CHECK when existing rows violate it.

Fix: an idempotent UPDATE libri SET rating = NULL WHERE rating IS NOT NULL AND rating NOT BETWEEN 1 AND 5 immediately before the ADD — bad rows are normalised first, valid 1–5 and NULL are preserved, and an install that already has the constraint has zero violating rows (pure no-op). Only affects installs upgrading through 0.7.31 fresh (the installer/updater won't re-run an already-applied migration).

🗄️ book-club Repo::findOrCreatePublisher — find-then-create race

The old SELECT id … ; INSERT … was a two-round-trip check-then-act TOCTOU: two near-simultaneous imports of the same publisher could create duplicate editori rows.

Fix: collapse it into one statement — INSERT INTO editori (nome) SELECT ? FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM editori WHERE nome = ?) — then a SELECT that resolves the lowest existing id. This removes the application-level race window.

Declined CodeRabbit's UNIQUE-index suggestion, deliberately: editori/autori are core tables that legitimately allow homonyms (the app dedups by canonical name, not raw nome) and the DB already holds duplicate names — so ADD UNIQUE would both fail on existing data and break the core app's author/publisher management. A plugin must not impose it. findOrCreateAuthor already routes through the core AuthorRepository.

Tests (reusable)

  • tests/migration-0.7.31-rating-cleanup.unit.php (9) — reproduces the CHECK failure on bad data, proves the UPDATE clears it (keeps 1–5/NULL), idempotent, and asserts the shipped file normalises before the ADD.
  • tests/bookclub-publisher-dedup.unit.php (9) — insert-if-absent + stable lowest-id resolution, no duplicate on repeat, reuses a pre-existing legacy duplicate.
  • Existing tests/migration-0.7.31.unit.php still green (28) — runs the real migration file including the new UPDATE.

PHPStan level 5 clean.

Summary by CodeRabbit

  • Correzioni

    • Le migrazioni ora gestiscono correttamente i valori di valutazione fuori dall’intervallo consentito, evitando errori durante l’aggiornamento del database.
    • Risolta la creazione di duplicati per gli editori del BookClub in caso di operazioni simultanee.
    • Le richieste concorrenti ora restituiscono lo stesso editore esistente.
  • Test

    • Aggiunti controlli per verificare la deduplicazione degli editori e la corretta gestione delle valutazioni non valide.

…r upsert

Two pre-existing findings CodeRabbit raised on merged work (not on any open PR).

migrate_0.7.31.sql: the guarded 'ADD CONSTRAINT chk_lt_rating CHECK (rating IS
NULL OR BETWEEN 1 AND 5)' fails and aborts the whole migration if libri already
holds an out-of-range rating (a legacy 0='unrated' or a 1-10 import). Add an
idempotent 'UPDATE libri SET rating = NULL WHERE rating IS NOT NULL AND rating
NOT BETWEEN 1 AND 5' immediately before the ADD, so bad rows are cleaned first;
NULLs (valid) are preserved and an install that already has the constraint has
no violating rows, so it's a no-op.

book-club Repo::findOrCreatePublisher: replace the SELECT-then-INSERT (a
check-then-act TOCTOU that let two near-simultaneous imports create duplicate
editori rows) with a single 'INSERT ... SELECT ? WHERE NOT EXISTS (...)' plus a
trailing SELECT that resolves the lowest existing id. This removes the
application-level race window. A UNIQUE index on editori.nome would make it fully
atomic, but editori/autori are CORE tables that legitimately allow homonyms (the
app dedups by canonical name, not raw name) and already hold duplicate names, so
a plugin must not add one — declining CodeRabbit's UNIQUE suggestion for that
reason. findOrCreateAuthor already routes through the core AuthorRepository.

Tests: migration-0.7.31-rating-cleanup.unit.php (9 — reproduces the CHECK
failure on bad data, proves the UPDATE clears it, idempotent, and asserts the
shipped file normalises before the ADD) and bookclub-publisher-dedup.unit.php
(9 — insert-if-absent + stable lowest-id resolution, no duplicate on repeat,
reuses a pre-existing legacy duplicate). Existing migration-0.7.31.unit.php
still green (28) — it runs the real file including the new UPDATE.
@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@fabiodalez-dev, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: c04e0ae9-e187-4383-a13a-5b24b7060344

📥 Commits

Reviewing files that changed from the base of the PR and between b052acd and 5539236.

📒 Files selected for processing (2)
  • storage/plugins/book-club/src/Repo.php
  • tests/bookclub-publisher-dedup.unit.php
📝 Walkthrough

Walkthrough

La migrazione normalizza i rating fuori intervallo prima della constraint CHECK. Il repository BookClub serializza lookup e inserimento dei publisher con lock advisory MySQL, con test per idempotenza, dati legacy e concorrenza.

Changes

Deduplicazione publisher

Layer / File(s) Summary
Lock e risoluzione atomica del publisher
storage/plugins/book-club/src/Repo.php
findOrCreatePublisher() acquisisce e rilascia un lock MySQL attorno a lookup e inserimento, gestendo timeout ed errori SQL.
Verifica della deduplicazione concorrente
tests/bookclub-publisher-dedup.unit.php
Il test verifica riuso degli ID, duplicati legacy, nomi distinti, concorrenza tra due connessioni, cleanup e presenza del protocollo GET_LOCK/RELEASE_LOCK.

Normalizzazione del rating

Layer / File(s) Summary
Normalizzazione prima della constraint
installer/database/migrations/migrate_0.7.31.sql
I rating non compresi tra 1 e 5 vengono impostati a NULL prima dell’aggiunta della constraint CHECK.
Verifica della migrazione e idempotenza
tests/migration-0.7.31-rating-cleanup.unit.php
Il test verifica l’ordine SQL, il fallimento iniziale della constraint, la normalizzazione, il successo successivo e l’idempotenza dell’UPDATE.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

  • fabiodalez-dev/Pinakes#247: modifica la stessa migrazione per la gestione di libri.rating, delle strutture di indice e della constraint.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Il titolo descrive correttamente le due modifiche principali: normalizzazione del rating e hardening dell'upsert del publisher.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/coderabbit-preexisting-migrate-bookclub

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.

Replace the insert-if-absent window-narrowing with a MySQL named lock
(GET_LOCK 'pinakes:bookclub:publisher', 10s timeout) held around the
SELECT-then-INSERT, so separate PHP workers fully serialize publisher
resolution — a true cross-connection guarantee that INSERT ... WHERE NOT
EXISTS could only approximate. editori.nome deliberately keeps no UNIQUE
(the core catalogue permits homonyms). The lock is always freed in a
finally; release logs but never throws, so it can't mask the original
error. findOrCreateAuthor still routes through the core AuthorRepository.

Test bookclub-publisher-dedup.unit.php now drives two connections to
prove the lock actually serializes overlapping imports onto the same
publisher id with exactly one row, alongside the single-thread
insert-if-absent + stable-id contract.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
storage/plugins/book-club/src/Repo.php (1)

163-208: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Il lock bloccante viene acquisito dentro una transazione con FOR UPDATE già aperta.

findOrCreatePublisher() è invocato da acquireExternalBook() (riga 838) dopo il SELECT ... FOR UPDATE (righe 818-826) e prima del commit() (riga 889). Poiché GET_LOCK() non viene rilasciato da commit/rollback ed è indipendente dalle transazioni InnoDB, un'attesa fino a 10s sul lock advisory globale mantiene il row-lock FOR UPDATE acquisito per tutto quel tempo. Sotto import concorrenti (bulk import di libri esterni con lo stesso editore), questo estende la contesa sulle righe bookclub_books/bookclub_external_books bloccate da FOR UPDATE, aumentando il rischio di lock-wait timeout su richieste concorrenti (es. deleteClubBook() che fa FOR UPDATE sulla stessa tabella).

Valutare di acquisire il lock publisher PRIMA di aprire la transazione in acquireExternalBook(), oppure spostare la risoluzione del publisher fuori dal blocco transazionale.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@storage/plugins/book-club/src/Repo.php` around lines 163 - 208, Sposta la
risoluzione del publisher fuori dalla transazione aperta da
acquireExternalBook(): acquisisci il lock advisory e completa
findOrCreatePublisher() prima del SELECT ... FOR UPDATE, oppure prima dell’avvio
della transazione. Mantieni il publisherId disponibile per il successivo
inserimento/aggiornamento transazionale e assicurati che il lock venga
rilasciato prima di acquisire i row-lock, evitando che l’attesa di
acquirePublisherLock() prolunghi la transazione.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@storage/plugins/book-club/src/Repo.php`:
- Line 182: Aggiorna findOrCreatePublisher(), acquirePublisherLock() e
releasePublisherLock() per verificare esplicitamente il risultato di
get_result() prima di chiamare fetch_assoc(), usando lo stesso pattern già
presente in rows(). Gestisci il caso false tramite il percorso d’errore
previsto, assicurando in particolare che un errore in releasePublisherLock()
dentro finally non mascheri l’eccezione originale.
- Around line 17-24: Scope the publisher advisory lock to the current database
because GET_LOCK() is server-wide. Update acquirePublisherLock() and
releasePublisherLock() to derive a shared lock name from PUBLISHER_LOCK_NAME
plus the active database name, following the existing ContributorBackfill.php
pattern, and use that derived name for both acquisition and release.

In `@tests/bookclub-publisher-dedup.unit.php`:
- Around line 70-112: Replace the duplicated closures $findOrCreateWhileLocked,
$acquireLock, $releaseLock, and $findOrCreate with calls to the real
Repo::findOrCreatePublisher() implementation, passing the test connection and
publisher name as required. Remove the textual GET_LOCK/RELEASE_LOCK source
check and assert the observable behavior of Repo::findOrCreatePublisher(),
including consistent IDs under concurrent or repeated creation.

---

Outside diff comments:
In `@storage/plugins/book-club/src/Repo.php`:
- Around line 163-208: Sposta la risoluzione del publisher fuori dalla
transazione aperta da acquireExternalBook(): acquisisci il lock advisory e
completa findOrCreatePublisher() prima del SELECT ... FOR UPDATE, oppure prima
dell’avvio della transazione. Mantieni il publisherId disponibile per il
successivo inserimento/aggiornamento transazionale e assicurati che il lock
venga rilasciato prima di acquisire i row-lock, evitando che l’attesa di
acquirePublisherLock() prolunghi la transazione.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9349f6f4-e7fc-4f9f-b0ee-2ddc384fb0d1

📥 Commits

Reviewing files that changed from the base of the PR and between 221f018 and b052acd.

📒 Files selected for processing (4)
  • installer/database/migrations/migrate_0.7.31.sql
  • storage/plugins/book-club/src/Repo.php
  • tests/bookclub-publisher-dedup.unit.php
  • tests/migration-0.7.31-rating-cleanup.unit.php

Comment thread storage/plugins/book-club/src/Repo.php
Comment thread storage/plugins/book-club/src/Repo.php Outdated
Comment thread tests/bookclub-publisher-dedup.unit.php
…t_result()

Address CodeRabbit review on #271:
- GET_LOCK is server-wide, so 'pinakes:bookclub:publisher' would serialize
  unrelated Pinakes installs sharing one MySQL server. Scope it per schema via
  GET_LOCK(CONCAT(?, ':', DATABASE()), ...) / RELEASE_LOCK(CONCAT(?, ':',
  DATABASE())), computed server-side, mirroring ContributorBackfill.
- Guard $stmt->get_result() === false before fetch_assoc() in the lookup,
  acquire, and release paths (as rows() already does). Critical in
  releasePublisherLock(), which runs in findOrCreatePublisher()'s finally: an
  uncaught Error there would mask the propagating exception — exactly what the
  lock protocol is meant to avoid.
- Strengthen the test's production-binding assertion to require the per-database
  CONCAT(...DATABASE()) scoping, so dropping it fails the test (the private
  method hardcodes the core editori table and can't be driven directly).
@fabiodalez-dev
fabiodalez-dev merged commit 5596969 into main Jul 20, 2026
10 checks passed
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.

1 participant