Skip to content

refactor: replace the remaining EntityManager.merge updates with find - #2151

Merged
netomi merged 1 commit into
mainfrom
refactor/merge-followup
Sep 4, 2026
Merged

refactor: replace the remaining EntityManager.merge updates with find#2151
netomi merged 1 commit into
mainfrom
refactor/merge-followup

Conversation

@netomi

@netomi netomi commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Second and final batch of the #989 audit — the 13 sites left after #2150. Between the two, every EntityManager.merge call site has been dealt with.

Stacked on #2150. The base is fix/entitymanager-merge-audit, so the diff here shows only this batch; GitHub will retarget it to main automatically once #2150 merges. Please review that one first.

Why these were subtler than the first batch

Every site here did intend an update, so none of them is dead code. The hazard is that merge writes every column of the detached copy, so any field that moved on the row since the caller loaded it is silently reverted — which is exactly how the original outage happened. These sites have long windows between load and write: a VSIX download and scan, a storage round-trip.

The clearest example is a pair that could revert each other. SetPreReleaseJobService writes preRelease/preview and CheckPotentiallyMaliciousExtensionVersionsService writes potentiallyMalicious; both loaded an ExtensionVersion, did slow VSIX work, then merged the whole row. Overlapping on the same version, whichever finished second undid the other's field — and either could revert active.

Service owns the mutation → find then set its own field

EclipseService#updateUserData, CustomerService#deactivateRateLimitToken, ExtensionVersionIntegrityService#setSignatureKeyPair, PublishExtensionVersionService#markExtensionAsPotentiallyMalicious, AccessTokenService#scheduleTokenExpirationNotification, SetPreReleaseJobService, CheckPotentiallyMaliciousExtensionVersionsService.

Worth noting that PublishExtensionVersionService#activateExtension already did exactly this, so the correct pattern was sitting next to the incorrect one in the same class; this makes the neighbours consistent with it.

Two methods renamed for the field they persist

MigrationService#updateResourceupdateResourceSize, RenameDownloadsService#updateResourceupdateResourceName.

Their old signatures couldn't express which field the caller had changed, which is precisely why they had to merge the whole row. Naming them for the field they write removes the hazard and stops a future caller setting something else and quietly having it dropped. Each had exactly one caller.

Merge before remove

MigrationService#deleteFileResource and the old namespace in ChangeNamespaceService now find and remove, skipping a row that has already gone rather than having merge try to resurrect it.

One merge deliberately kept

ChangeNamespaceService keeps a single merge, now documented:

} else {
    // Deliberately a merge, unlike the rest of #989's call sites: the caller mutates this
    // detached namespace before handing it over (ChangeNamespaceJobRequestHandler sets
    // logoName on it), so the update belongs to the caller and only merge can carry it.
    // find-then-set would silently drop that rename.
    newNamespace = entityManager.merge(newNamespace);
}

I nearly converted this one before reading the caller. So the conclusion of the audit isn't "merge is wrong" — it's that merge is wrong when the service owns the mutation, and right when the caller does. That distinction is what the comment is there to preserve. The same method's updatedResources loop only ever changes the name, so that one writes just the name.

CustomerService needed something to throw for the vanished-row case; it uses ErrorResultException, which the class already imports and uses for a missing row, rather than introducing a different exception type.

Testing

Full server suite green (1145 tests). Three new cases on markExtensionAsPotentiallyMalicious: the flag landing on the managed row, a concurrent activation surviving the write (the reverted-column case, which is the point of the change), and a version purged in the meantime being ignored rather than resurrected.

Fixes #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.

Copilot wasn't able to review any files in this pull request.


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

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 changes consistently apply the safer “load managed then set” pattern (or find+remove) and include targeted tests covering the previously risky stale-merge failure mode.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

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 force-pushed the refactor/merge-followup branch from d931058 to cd74779 Compare September 3, 2026 20:58
netomi added a commit that referenced this pull request Sep 4, 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 force-pushed the refactor/merge-followup branch from cd74779 to af3fb7f Compare September 4, 2026 12:35
Base automatically changed from fix/entitymanager-merge-audit to main September 4, 2026 12:48
netomi added a commit that referenced this pull request Sep 4, 2026
…2150)

* refactor: stop using EntityManager.merge to obtain managed entities

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>

* fix: guard the new find() lookups against a missing row

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>

* docs: add AGENTS.md for the server, leading with the merge rule

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>

* fix: log which migration item was gone, and trim the ownership comment

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>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second and final batch of the #989 audit, covering the 13 sites left
after the previous one. These all did intend an update, so the hazard
was subtler: merge writes every column of the detached copy, meaning
any field that moved on the row since the caller loaded it is silently
reverted. That is how the original outage happened, and these sites had
long windows - a VSIX download and scan, a storage round-trip - between
load and write.

Where the service itself owns the mutation, it now finds the row by id
and sets just its own field: EclipseService.updateUserData,
CustomerService.deactivateRateLimitToken,
ExtensionVersionIntegrityService.setSignatureKeyPair,
PublishExtensionVersionService.markExtensionAsPotentiallyMalicious,
AccessTokenService.scheduleTokenExpirationNotification,
SetPreReleaseJobService and
CheckPotentiallyMaliciousExtensionVersionsService. The last two are the
pair that could revert each other: both loaded an ExtensionVersion, did
slow VSIX work, then merged the whole row, so whichever finished second
undid the other's field. PublishExtensionVersionService.activateExtension
already used find, so this makes its neighbours consistent with it.

MigrationService.updateResource and RenameDownloadsService.updateResource
are now updateResourceSize and updateResourceName. Their old signatures
could not say which field the caller had changed, which is precisely why
they had to merge the whole row; naming them for the field they persist
removes the hazard and stops the next caller expecting a different field
to be written. Each had exactly one caller.

The merge-before-remove pair (MigrationService.deleteFileResource and
the old namespace in ChangeNamespaceService) now find and remove,
skipping a row that has already gone instead of having merge try to
resurrect it.

ChangeNamespaceService keeps one merge, now documented. Its caller
mutates the detached namespace before handing it over
(ChangeNamespaceJobRequestHandler sets logoName), so that update belongs
to the caller and only merge can carry it - find-then-set would silently
drop the logo rename. Its updatedResources loop, by contrast, only ever
changes the name, so that one writes just the name.

Three new tests on markExtensionAsPotentiallyMalicious cover the flag
landing on the managed row, a concurrent activation surviving the write
(the reverted-column case), and a version purged in the meantime being
ignored.

Fixes #989

Co-Authored-By: Claude Opus 5 (1M context) <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.

🟢 Approval recommended

The refactors consistently replace risky detached-entity merges with managed-row updates (plus safe missing-row handling) and include targeted tests for the key concurrency regression scenario.

Review details
  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new
  • Review effort level: Lite

@netomi
netomi merged commit 9018816 into main Sep 4, 2026
6 checks passed
@netomi
netomi deleted the refactor/merge-followup branch September 4, 2026 13:06
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.

Review usage of EntityManager.merge

2 participants