Skip to content

fix: prevent duplicate active reviews from concurrent requests - #2245

Merged
netomi merged 6 commits into
mainfrom
fix/postreview-duplicate-race
Sep 25, 2026
Merged

netomi merged 6 commits into
mainfrom
fix/postreview-duplicate-race

Conversation

@netomi

@netomi netomi commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

Summary

  • LocalRegistryService.postReview checks 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.
  • Added a partial unique index (unique_active_review on extension_review(extension_id, user_id) WHERE active) as the actual enforcement point, and @Retryable around postReview to 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.
  • The migration is partial (WHERE active) rather than a plain unique constraint because deleteReview soft-deletes (sets active = false) rather than removing rows, so a user can legitimately hold multiple inactive reviews over time — just never more than one active one.
  • The migration also cleans up any duplicates that may already exist in a given deployment: it deletes all but the most recent active review per user/extension, and recomputes the affected extensions' average_rating/review_count before adding the index. Ranked by timestamp, not id (review finding) — ExtensionReview's sequence generator has no explicit allocationSize, so JPA defaults it to 50, matching the sequence's INCREMENT 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.
  • CI caught a real bug in the first version of this PR: @EnableResilientMethods defaults to interface-based (JDK dynamic) proxying, and LocalRegistryService implements IExtensionRegistry. Adding @Retryable to postReview made Spring proxy the bean against that interface instead of the concrete class, breaking every other bean (RegistryAPI, UserAPI, AdminAPI) wired to the concrete LocalRegistryService type — a BeanNotOfRequiredTypeException at context startup that failed essentially the whole @WebMvcTest suite. Fixed by switching to @EnableResilientMethods(proxyTargetClass = true), matching how @EnableCaching is already configured in this codebase for the same reason. Checked every other @Retryable user in the codebase (PublishExtensionVersionHandler, MigrationService, PublishExtensionVersionService, ElasticSearchService) — this is backward-compatible for all of them.
  • The migration's cleanup wasn't serialized against live writers (review finding): the Kubernetes manifest uses the default rolling-update strategy and Flyway runs at each instance's own startup (no separate migration job), so an old-version pod can still be serving postReview() while a new pod runs this migration. A plain INSERT landing between the DELETE and CREATE UNIQUE INDEX would reintroduce a duplicate and fail the whole migration. Fixed with LOCK TABLE extension_review IN SHARE MODE as the first statement — it conflicts with the ROW EXCLUSIVE every 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 jooqCodegen per server/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 new Indexes.java entry plus adding it to ExtensionReview.java's getIndexes(), matching the unique_active_signature_key_pair_idx precedent exactly as predicted.

Test plan

  • Added LocalRegistryServicePostReviewConcurrencyTest, modeled directly on the existing PublishExtensionVersionConcurrencyTest: a @MockitoSpyBean on RepositoryService intercepts the duplicate-review check and injects a genuinely concurrent second postReview call in that exact window. Asserts exactly one call succeeds, the other gets the polite rejection, and only one active review row exists afterward.
  • ./gradlew spotlessCheck and ./gradlew compileTestJava pass.
  • Confirmed on CI (this environment has no working Docker, so I couldn't run the Testcontainers-backed test myself): LocalRegistryServicePostReviewConcurrencyTest passed against a real PostgreSQL instance, and the full server test suite is green after the proxyTargetClass fix.

🤖 Generated with Claude Code

netomi and others added 3 commits September 25, 2026 10:29
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.
@netomi
netomi requested review from cstamas and a balanced review from Copilot September 25, 2026 11:55

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 High severity

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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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 High severity · 1 Medium severity

Open (2)
Resolved since last review (1)

netomi and others added 2 commits September 25, 2026 12:31
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>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

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

@netomi
netomi merged commit edab3e4 into main Sep 25, 2026
6 checks passed
@netomi
netomi deleted the fix/postreview-duplicate-race branch September 25, 2026 13:11
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.

3 participants