Repository navigation
fix: prevent duplicate active reviews from concurrent requests - #2245
Conversation
postReview() checks for an existing active review and then inserts a new one, but those two steps aren't serialized against each other. Two requests from the same user submitted close together can both pass the check before either has committed, leaving the user with more than one active review for the same extension and skewing its average rating and review count. Added a partial unique index on extension_review(extension_id, user_id) WHERE active, and retry postReview() once on the resulting constraint violation, mirroring the existing pattern for concurrent extension publications (see PublishExtensionVersionHandler.DuplicateExtensionPredicate). On a losing race, the retry re-runs against a fresh transaction, sees the now-committed review, and returns the normal "already reviewed" message instead of a raw database error. The migration also cleans up any duplicates that may already exist, keeping the oldest review per user/extension and recomputing the affected extensions' average rating and review count. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@EnableResilientMethods defaults to interface-based (JDK dynamic) proxying. LocalRegistryService implements IExtensionRegistry, so adding @retryable to postReview() made Spring proxy it against that interface instead of the concrete class - breaking every other bean (RegistryAPI, UserAPI, AdminAPI) that's wired to the concrete LocalRegistryService type, with a BeanNotOfRequiredTypeException at context startup. Switched to proxyTargetClass = true, matching how @EnableCaching is already configured in this codebase for the same reason. This is a global, backward-compatible change: the other @retryable beans either have no interface (PublishExtensionVersionHandler, MigrationService, PublishExtensionVersionService, where CGLIB was already the fallback) or implement one that nothing depends on for the concrete type (ElasticSearchService), so nothing else was relying on interface-based proxying here. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cleanup step in V1_76 kept the oldest active review per (extension, user) and soft-deactivated the rest. Delete them outright instead, keeping the most recent (highest id) one, since these rows only exist because of the race this migration closes and there's no reason to keep them around as inactive history.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The migration can retain an older review because pooled sequence IDs are not chronological across instances.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (1)
What changed in this PR
Prevents concurrent requests from creating duplicate active extension reviews.
Changes:
- Adds duplicate cleanup and a partial unique index.
- Retries review creation after matching constraint violations.
- Enables class-based resilience proxies and adds concurrency coverage.
| File | Description |
|---|---|
LocalRegistryServicePostReviewConcurrencyTest.java |
Tests concurrent review submission. |
V1_76__Unique_Active_Review.sql |
Deduplicates reviews and adds uniqueness enforcement. |
RegistryApplication.java |
Enables CGLIB resilience proxies. |
LocalRegistryService.java |
Retries duplicate-review races. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
ExtensionReview's @SequenceGenerator has no explicit allocationSize, so JPA defaults it to 50, matching the extension_review_seq's INCREMENT 50 - a pooled allocator where separate application instances hold different id blocks. A later insert on one instance can end up with a lower id than an earlier insert on another, so "highest id" is not a reliable proxy for "most recent" across instances. The cleanup step could therefore delete a user's genuinely newer review and keep a stale one. Rank by timestamp DESC NULLS LAST instead, using id only to break ties on an identical or missing timestamp, via the same DISTINCT ON idiom already used for this kind of per-group cleanup in V1_39__Fix_Unique_Constraints.sql. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
The migration needs serialization against live writers and regenerated jOOQ metadata.
Get a fresh assessment by requesting another Copilot review.
Review effort: Balanced
Findings: 1
Open (2)
Resolved since last review (1)
During a rolling deployment, an old-version instance can still be serving postReview() requests while a new instance runs this migration. A plain INSERT from that instance landing between the DELETE and CREATE UNIQUE INDEX would reintroduce a duplicate the DELETE already cleaned up, failing the index creation and the whole migration. Take a table-level SHARE lock as the first statement, before the cleanup snapshot. SHARE conflicts with the ROW EXCLUSIVE lock every INSERT/ UPDATE/DELETE needs, so concurrent writers block until this migration's transaction commits, but readers are unaffected. Flyway runs the whole script in one transaction, so the lock is held through CREATE UNIQUE INDEX. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Ran ./gradlew jooqCodegen against V1_76's new index. Adds the UNIQUE_ACTIVE_REVIEW entry to Indexes.java and includes it in ExtensionReview.getIndexes(), matching the existing unique_active_signature_key_pair_idx precedent. Addresses a review finding on PR #2245. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Copilot review overview
🟢 Approval recommended
The database constraint, transactional retry, migration safety, generated metadata, and concurrency test consistently address the race.
Review effort: Balanced
Findings: None
Resolved since last review (2)
Files not reviewed (2)
- server/src/main/jooq-gen/org/eclipse/openvsx/jooq/Indexes.java: Generated file
- server/src/main/jooq-gen/org/eclipse/openvsx/jooq/tables/ExtensionReview.java: Generated file


Summary
LocalRegistryService.postReviewchecks whether a user already has an active review for an extension, then inserts a new one — but those two steps aren't serialized against each other. Two requests from the same user submitted close together can both pass the check before either commits, leaving the user with more than one active review for the same extension and skewing its average rating and review count.unique_active_reviewonextension_review(extension_id, user_id) WHERE active) as the actual enforcement point, and@RetryablearoundpostReviewto retry once on the resulting constraint violation — the same pattern already used for concurrent extension publications (PublishExtensionVersionHandler.DuplicateExtensionPredicate). On a losing race, the retry re-runs in a fresh transaction, sees the now-committed review, and returns the normal "already reviewed" message instead of a raw database error.WHERE active) rather than a plain unique constraint becausedeleteReviewsoft-deletes (setsactive = false) rather than removing rows, so a user can legitimately hold multiple inactive reviews over time — just never more than one active one.average_rating/review_countbefore adding the index. Ranked bytimestamp, notid(review finding) —ExtensionReview's sequence generator has no explicitallocationSize, so JPA defaults it to 50, matching the sequence'sINCREMENT 50; that's a pooled allocator, so separate application instances can hold different id blocks and a later insert can end up with a lower id than an earlier one on another instance.@EnableResilientMethodsdefaults to interface-based (JDK dynamic) proxying, andLocalRegistryServiceimplementsIExtensionRegistry. Adding@RetryabletopostReviewmade Spring proxy the bean against that interface instead of the concrete class, breaking every other bean (RegistryAPI,UserAPI,AdminAPI) wired to the concreteLocalRegistryServicetype — aBeanNotOfRequiredTypeExceptionat context startup that failed essentially the whole@WebMvcTestsuite. Fixed by switching to@EnableResilientMethods(proxyTargetClass = true), matching how@EnableCachingis already configured in this codebase for the same reason. Checked every other@Retryableuser in the codebase (PublishExtensionVersionHandler,MigrationService,PublishExtensionVersionService,ElasticSearchService) — this is backward-compatible for all of them.postReview()while a new pod runs this migration. A plainINSERTlanding between theDELETEandCREATE UNIQUE INDEXwould reintroduce a duplicate and fail the whole migration. Fixed withLOCK TABLE extension_review IN SHARE MODEas the first statement — it conflicts with theROW EXCLUSIVEevery writer needs (blocking them until this migration's transaction commits) without blocking readers, and Flyway wraps the whole script in one transaction so the lock holds through index creation.src/main/jooq-gen/needed regenerating for the new index (review finding,./gradlew jooqCodegenperserver/AGENTS.md) — I couldn't run that myself (needs a live Postgres, unavailable in this sandbox), so this was run against a local dev Postgres and applied here: one newIndexes.javaentry plus adding it toExtensionReview.java'sgetIndexes(), matching theunique_active_signature_key_pair_idxprecedent exactly as predicted.Test plan
LocalRegistryServicePostReviewConcurrencyTest, modeled directly on the existingPublishExtensionVersionConcurrencyTest: a@MockitoSpyBeanonRepositoryServiceintercepts the duplicate-review check and injects a genuinely concurrent secondpostReviewcall in that exact window. Asserts exactly one call succeeds, the other gets the polite rejection, and only one active review row exists afterward../gradlew spotlessCheckand./gradlew compileTestJavapass.LocalRegistryServicePostReviewConcurrencyTestpassed against a real PostgreSQL instance, and the full server test suite is green after theproxyTargetClassfix.🤖 Generated with Claude Code