refactor: stop using EntityManager.merge to obtain managed entities - #2150
Conversation
There was a problem hiding this comment.
🟡 Changes recommended
Several new EntityManager.find(...) usages are dereferenced without null-handling, which can introduce NPEs if rows are deleted or principals are stale between transactions.
Once you've addressed the issues Copilot identified, you can request another Copilot review.
Pull request overview
This PR is the first batch of the #989 audit to remove unintended side effects from EntityManager.merge(...) calls that were used only to obtain managed entities (or were no-ops), replacing them with find(...), dropping redundant merges, and tightening access-token ownership checks to compare user identity by id.
Changes:
- Replaced
mergewithfindin multiple read-only / association-target paths to prevent detached-entity state from being written back unintentionally. - Removed redundant
mergecalls where entities are already managed within the surrounding transaction. - Updated access-token deactivation ownership checks to compare user ids (and added tests/fixtures to cover and enforce the new behavior).
File summaries
| File | Description |
|---|---|
| server/src/test/java/org/eclipse/openvsx/UserAPITest.java | Updates token-deletion test setup to reflect removal of merge(user) and ensures fixture users have assigned ids. |
| server/src/test/java/org/eclipse/openvsx/accesstoken/AccessTokenServiceTest.java | Adds regression tests for id-based ownership checks and ensures EntityManager is no longer invoked during deactivation. |
| server/src/main/java/org/eclipse/openvsx/trustedpublishing/TrustedPublishingService.java | Uses find(UserData, id) instead of merge(user) when setting the createdBy association. |
| server/src/main/java/org/eclipse/openvsx/storage/log/DownloadCountProcessor.java | Uses find(Extension, id) (and skips eviction if missing) to avoid merge side effects during cache eviction. |
| server/src/main/java/org/eclipse/openvsx/migration/RenameDownloadsService.java | Uses find(FileResource, id) instead of merge(resource) to avoid unintended writes when cloning a resource. |
| server/src/main/java/org/eclipse/openvsx/migration/MigrationService.java | Uses find(MigrationItem, id) instead of merge(item) for read-only scheduling logic. |
| server/src/main/java/org/eclipse/openvsx/eclipse/PublisherComplianceChecker.java | Drops redundant merges and removes EntityManager dependency where entities are already managed in the transaction template. |
| server/src/main/java/org/eclipse/openvsx/admin/ChangeNamespaceService.java | Removes redundant merge in a transactional namespace-change path where entities are already managed. |
| server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java | Switches token ownership verification from entity equality to id comparison and avoids merging the caller’s UserData. |
Review details
Suppressed comments (2)
server/src/main/java/org/eclipse/openvsx/migration/MigrationService.java:94
entityManager.findcan return null if the MigrationItem row was deleted between fetching the slice and entering this transaction. The subsequentitem.getJobName()dereference would then throw an NPE and abort scheduling for the batch; handle the missing row explicitly (log + skip, or throw a clear exception).
// Only read from here on, so find rather than merge - see #989.
item = entityManager.find(MigrationItem.class, item.getId());
var jobIdText = item.getJobName() + "->itemId=" + item.getId();
var jobId = uuidService.generateFromName(jobIdText);
var handler = JOB_HANDLERS.get(item.getJobName());
scheduler.schedule(jobId, scheduledAt, new MigrationJobRequest<>(handler, item.getEntityId(), item.getId()));
server/src/main/java/org/eclipse/openvsx/migration/RenameDownloadsService.java:35
entityManager.findcan return null; if the FileResource row no longer exists, the followingresource.getStorageType()/getType()/getExtension()calls will throw an NPE. Add an explicit null check so the failure mode is deterministic.
resource = entityManager.find(FileResource.class, resource.getId());
var clone = new FileResource();
clone.setName(name);
clone.setStorageType(resource.getStorageType());
clone.setType(resource.getType());
- Files reviewed: 9/9 changed files
- Comments generated: 2
- Review effort level: Lite
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
171a8a7 to
5d641f4
Compare
Review feedback on #2150: three of the find() calls this PR introduced dereferenced the result without checking it, so a row deleted between the caller loading it and the transaction running would surface as an NPE. The second batch in #2151 guards every lookup; these should have matched it. Each fails in the way that suits its caller: - MigrationService.scheduleMigration skips the item. Its caller loops over a whole batch, so aborting would drop the remaining items; MigrationItemCleanupFilter and the Delete_MigrationItems migrations both delete these rows, which is how the row goes missing. - RenameDownloadsService.cloneResource throws with the resource id. The caller uses the returned clone immediately, so there is nothing useful to skip - better a deterministic failure than an NPE three lines later. - TrustedPublishingService throws an ErrorResultException, matching the rest of that class. trusted_publisher.created_by is NOT NULL (V1_72), so a user that has gone away since the request was authenticated would otherwise fail as a constraint violation on persist. Also corrects the comment on scheduleMigration: it does write, via setMigrationScheduled below, so find there is the find-then-set pattern rather than a pure read as the comment claimed. Refs #989 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🟢 Approval recommended
The refactor removes risky merge usage in favor of side-effect-free reads and is supported by focused tests, with only minor maintainability nits remaining.
Review details
- Files reviewed: 11/11 changed files
- Comments generated: 2
- Review effort level: Lite
First batch of the #989 audit: the sites where merge was never meant to update anything, plus the ones where it did nothing at all. DownloadCountProcessor.evictCaches is the same defect that caused the original outage, in a new place. Its own comment says why the merge is there - "needs transaction for lazy-loading versions" - which is hoangphamEclipse's diagnosis almost word for word. Nothing there updates the extension; it is cache eviction. But merge wrote the whole row back, and the entity had already been loaded and mutated (setDownloadCount) in an earlier step of the batch, so a concurrent update could be silently reverted. It now uses find, and skips eviction if the extension has since been deleted rather than having merge try to resurrect the row. Also switched to find where merge only ever fed a read: TrustedPublishingService (merged the caller's user purely to get an association target for the new row), MigrationService.scheduleMigration (reads getJobName/getEntityId), RenameDownloadsService.cloneResource (reads fields for the clone; find returns a managed entity too, so the lazy getExtension still resolves). Dropped three merges that were plain no-ops, having been handed already-managed entities: ChangeNamespaceService.changeExtensionNamespace runs inside its own @transactional method, and both in PublisherComplianceChecker run inside checkPublishers' TransactionTemplate. That leaves PublisherComplianceChecker with no use for an EntityManager, so the dependency is gone. AccessTokenService.deactivateAccessToken needed more than a deletion. UserData.equals compares every field, including tokens and memberships, so the merge was load-bearing: it made `user` the same managed instance as token.getUser() and let equals short-circuit on ==. Removing it alone would reject any caller holding a slightly stale user. The ownership check now compares ids, which is what it should have done regardless. It also requires the id to be assigned: two never-persisted entities both report id 0, so a bare id comparison fails open. The existing testDeleteAccessTokenWrongUser caught exactly that - its two users were both id 0 - so the fixtures now carry real ids, which is also what production entities always have. Still to do, tracked in #989: the merge-before-remove pair, and the 11 sites where an update is intended but merging a whole detached copy can revert columns that changed in the meantime. Refs #989 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review feedback on #2150: three of the find() calls this PR introduced dereferenced the result without checking it, so a row deleted between the caller loading it and the transaction running would surface as an NPE. The second batch in #2151 guards every lookup; these should have matched it. Each fails in the way that suits its caller: - MigrationService.scheduleMigration skips the item. Its caller loops over a whole batch, so aborting would drop the remaining items; MigrationItemCleanupFilter and the Delete_MigrationItems migrations both delete these rows, which is how the row goes missing. - RenameDownloadsService.cloneResource throws with the resource id. The caller uses the returned clone immediately, so there is nothing useful to skip - better a deterministic failure than an NPE three lines later. - TrustedPublishingService throws an ErrorResultException, matching the rest of that class. trusted_publisher.created_by is NOT NULL (V1_72), so a user that has gone away since the request was authenticated would otherwise fail as a constraint violation on persist. Also corrects the comment on scheduleMigration: it does write, via setMigrationScheduled below, so find there is the find-then-set pattern rather than a pure read as the comment claimed. Refs #989 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The webui has had one since August; the server had nothing, so every convention here was folk knowledge - which is how #989 got reintroduced twice after the original report. The persistence section is the reason this file exists now. It states what merge actually does, says to reach for find instead, and gives the five questions a call site has to answer - each one drawn from a real site in this audit rather than from the JPA spec: the lazy-loading motive from DownloadCountProcessor, the no-op merges in PublisherComplianceChecker, the stale-after-slow-work shape shared by SetPreReleaseJobService and CheckPotentiallyMaliciousExtensionVersionsService, the UserData#equals identity trap in AccessTokenService, and find's null for a row that merge would resurrect. The rest records what a contributor currently has to discover by reading build.gradle: spotless being opt-in with pre-existing violations elsewhere, jooq-gen being generated and committed, migrations being immutable once shipped, and JSpecify for nullability. The deployment section is there because the Kubernetes configmap has now twice been left behind by a config change that updated the other three, both times leaving keys bound to nothing. CLAUDE.md is a symlink to it, matching webui, so Claude Code and other agents read the same file. Refs #989 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two review points from Copilot. The "row missing" debug log named no item, so it could not be correlated with the job handler or cleanup output either side of it - and the parameter it could have come from is reassigned by the find on the line above. Captured first, as cloneResource in the same commit already does. The ownership comment ran to nine lines, against the 1-3 the AGENTS.md added in this same branch asks for. Most of it was history: that the check used to merge, and why dropping that merge alone would have broken it. That belongs in this branch's earlier commit message, not at the call site forever. What is left is the part a future reader needs - why ids rather than entities, and why id 0 is rejected. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
81bde8c to
2c5230c
Compare
First batch of the #989 audit — the sites where
EntityManager.mergewas never meant to update anything, plus the ones where it did nothing at all. 8 of the 21 call sites; the remaining 13 are listed at the bottom.Context: the instance originally reported by @hoangphamEclipse (
VersionService.java:38, where merging a stale detached entity on a read path silently reverted a concurrent write and left extensions inactive) is long gone. This is the audit @amvanbaren asked for on top of it.The same defect, in a new place
DownloadCountProcessor#evictCacheswas carrying the motive in its own comment:That is @hoangphamEclipse's diagnosis almost word for word — the merge existed only so the lazy version collections could be loaded. Nothing there updates the extension; it is cache eviction. But it wrote the whole row back, and the entity had already been loaded and mutated (
setDownloadCount) in an earlier step of the same batch, so a concurrent update could be reverted exactly as before.Now uses
find, and skips eviction if the extension has since been deleted rather than lettingmergeattempt to resurrect the row.The rest of this batch
DownloadCountProcessor#evictCachesfindTrustedPublishingServicefindMigrationService#scheduleMigrationgetJobName/getEntityIdfindRenameDownloadsService#cloneResourcefindChangeNamespaceService#changeExtensionNamespacePublisherComplianceChecker(×2)AccessTokenService#deactivateAccessTokenequalsshort-circuitThe three dropped ones were plain no-ops:
changeExtensionNamespaceruns inside its own@Transactionalmethod and bothPublisherComplianceCheckermerges run insidecheckPublishers'TransactionTemplate, so all three were handed entities that were already managed and merge just returned the same instance — the setters are what persist. That leavesPublisherComplianceCheckerwith no use for anEntityManager, so the dependency is gone.RenameDownloadsService#cloneResourcestill needs a managed resource, because it reads the lazygetExtension();findprovides that just as well, without the write.The one that needed care
AccessTokenService#deactivateAccessTokenlooked like a free deletion — merge, then onlyequalsandgetLoginName(). It wasn't.UserData#equalscompares every field, includingtokensandmemberships, so the merge was load-bearing: it madeuserthe same managed instance astoken.getUser()and letequalsshort-circuit on==. Dropping it alone would start rejecting any caller holding a user that differs from the stored row in any field. The ownership check now compares ids, which is what it should have been doing regardless.It also requires the id to be assigned, and that part came from the test suite rather than from me. My first version compared ids alone, and
testDeleteAccessTokenWrongUserfailed: its two users were both id 0, becausemockUserData()never set one, so an unpersisted entity made the check fail open and a stranger could deactivate the token. The fixtures now carry real ids — which production entities always do — and the check rejects an unassigned id outright.Testing
Full server suite green (1142 tests). Three new cases in
AccessTokenServiceTestcover the ownership check: the owner path (assertingverifyNoInteractions(entityManager), i.e. that the user is no longer written back), a different user, and a token with no user at all. Confirmed non-vacuous — two of the three fail against the old code, including an NPE on the ownerless token that the oldtoken.getUser().equals(user)would have thrown.Deliberately not in this PR
Left for follow-ups so this stays reviewable, and because they need per-site judgement rather than a find-and-replace:
MigrationService#deleteFileResource,ChangeNamespaceServiceold namespace) — works, but emits a pointless UPDATE ahead of the DELETE.EclipseService#updateUserData,PublishExtensionVersionService#markExtensionAsPotentiallyMalicious,ExtensionVersionIntegrityService#setSignatureKeyPair,CustomerService#deactivateRateLimitToken) — each merges a detached entity to set one field, so any column that changed meanwhile is reverted.find-then-set is the safer shape. Worth knowing thatSetPreReleaseJobService(writespreRelease/preview) andCheckPotentiallyMaliciousExtensionVersionsService(writespotentiallyMalicious) both load anExtensionVersion, do slow VSIX work, then merge the whole row — overlapping on the same version, the later merge reverts the other's field, and either can revertactive.Refs #989
🤖 Generated with Claude Code