Skip to content

refactor: stop using EntityManager.merge to obtain managed entities - #2150

Merged
netomi merged 4 commits into
mainfrom
fix/entitymanager-merge-audit
Sep 4, 2026
Merged

refactor: stop using EntityManager.merge to obtain managed entities#2150
netomi merged 4 commits into
mainfrom
fix/entitymanager-merge-audit

Conversation

@netomi

@netomi netomi commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

First batch of the #989 audit — the sites where EntityManager.merge was 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#evictCaches was carrying the motive in its own comment:

@Transactional // needs transaction for lazy-loading versions
public void evictCaches(Extension extension) {
    var mergedExtension = entityManager.merge(extension);

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 letting merge attempt to resurrect the row.

The rest of this batch

site was now
DownloadCountProcessor#evictCaches merge to load lazy collections find
TrustedPublishingService merge the caller's user to get an association target find
MigrationService#scheduleMigration merge, then only read getJobName/getEntityId find
RenameDownloadsService#cloneResource merge, then only read fields for the clone find
ChangeNamespaceService#changeExtensionNamespace merge of an already-managed entity dropped
PublisherComplianceChecker (×2) merge of already-managed entities dropped
AccessTokenService#deactivateAccessToken merge to make equals short-circuit id comparison

The three dropped ones were plain no-ops: changeExtensionNamespace runs inside its own @Transactional method and both PublisherComplianceChecker merges run inside checkPublishers' TransactionTemplate, so all three were handed entities that were already managed and merge just returned the same instance — the setters are what persist. That leaves PublisherComplianceChecker with no use for an EntityManager, so the dependency is gone.

RenameDownloadsService#cloneResource still needs a managed resource, because it reads the lazy getExtension(); find provides that just as well, without the write.

The one that needed care

AccessTokenService#deactivateAccessToken looked like a free deletion — merge, then only equals and getLoginName(). It wasn't. 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 ==. 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 testDeleteAccessTokenWrongUser failed: its two users were both id 0, because mockUserData() 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 AccessTokenServiceTest cover the ownership check: the owner path (asserting verifyNoInteractions(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 old token.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:

  • merge-before-remove (2 sites: MigrationService#deleteFileResource, ChangeNamespaceService old namespace) — works, but emits a pointless UPDATE ahead of the DELETE.
  • update intended, whole detached copy merged (11 sites, incl. 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 that SetPreReleaseJobService (writes preRelease/preview) and CheckPotentiallyMaliciousExtensionVersionsService (writes potentiallyMalicious) both load an ExtensionVersion, 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 revert active.

Refs #989

🤖 Generated with Claude Code

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.

🟡 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 merge with find in multiple read-only / association-target paths to prevent detached-entity state from being written back unintentionally.
  • Removed redundant merge calls 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.find can return null if the MigrationItem row was deleted between fetching the slice and entering this transaction. The subsequent item.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.find can return null; if the FileResource row no longer exists, the following resource.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.

@netomi netomi closed this Sep 3, 2026
@netomi
netomi force-pushed the fix/entitymanager-merge-audit branch from 171a8a7 to 5d641f4 Compare September 3, 2026 20:38
@netomi netomi reopened this Sep 3, 2026
netomi added a commit that referenced this pull request Sep 3, 2026
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>
@netomi
netomi requested a lite review from Copilot September 4, 2026 12:23

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.

🟢 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

Comment thread server/src/main/java/org/eclipse/openvsx/accesstoken/AccessTokenService.java Outdated
netomi and others added 4 commits September 4, 2026 14:35
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>
@netomi
netomi force-pushed the fix/entitymanager-merge-audit branch from 81bde8c to 2c5230c Compare September 4, 2026 12:35
@netomi
netomi merged commit 1f4b832 into main Sep 4, 2026
5 checks passed
@netomi
netomi deleted the fix/entitymanager-merge-audit branch September 4, 2026 12:48
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