refactor: replace the remaining EntityManager.merge updates with find - #2151
Merged
Conversation
netomi
force-pushed
the
refactor/merge-followup
branch
from
September 3, 2026 20:38
7f214ec to
5d641f4
Compare
Contributor
There was a problem hiding this comment.
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.
Contributor
There was a problem hiding this comment.
🟢 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
force-pushed
the
refactor/merge-followup
branch
from
September 3, 2026 20:58
d931058 to
cd74779
Compare
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
force-pushed
the
refactor/merge-followup
branch
from
September 4, 2026 12:35
cd74779 to
af3fb7f
Compare
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>
netomi
force-pushed
the
refactor/merge-followup
branch
from
September 4, 2026 12:58
af3fb7f to
0561892
Compare
Contributor
There was a problem hiding this comment.
🟢 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Second and final batch of the #989 audit — the 13 sites left after #2150. Between the two, every
EntityManager.mergecall site has been dealt with.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
mergewrites 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.
SetPreReleaseJobServicewritespreRelease/previewandCheckPotentiallyMaliciousExtensionVersionsServicewritespotentiallyMalicious; both loaded anExtensionVersion, 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 revertactive.Service owns the mutation →
findthen set its own fieldEclipseService#updateUserData,CustomerService#deactivateRateLimitToken,ExtensionVersionIntegrityService#setSignatureKeyPair,PublishExtensionVersionService#markExtensionAsPotentiallyMalicious,AccessTokenService#scheduleTokenExpirationNotification,SetPreReleaseJobService,CheckPotentiallyMaliciousExtensionVersionsService.Worth noting that
PublishExtensionVersionService#activateExtensionalready 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#updateResource→updateResourceSize,RenameDownloadsService#updateResource→updateResourceName.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#deleteFileResourceand the old namespace inChangeNamespaceServicenowfindandremove, skipping a row that has already gone rather than havingmergetry to resurrect it.One merge deliberately kept
ChangeNamespaceServicekeeps a single merge, now documented: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
updatedResourcesloop only ever changes the name, so that one writes just the name.CustomerServiceneeded something to throw for the vanished-row case; it usesErrorResultException, 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