Skip to content

Commit bc4a151

Browse files
gnugomeznetomi
andauthored
fix: serialize deleteExtension and publish with a pessimistic lock (#1919)
* test: cover deleteExtension racing a concurrent publish Adds tests for an extension delete-all racing a publish of a new version: the concurrently published version must not be silently deleted/orphaned, a delete must fail fast (and ask to retry) while a publish holds the extension lock, and deleting your own version while another publisher has one must leave the extension and that version intact. Assisted-By: anthropic:claude-opus-4-8[1m] * fix: serialize deleteExtension and publish with a pessimistic lock Publishing now takes a waiting FOR UPDATE lock on the extension before adding a version, and deleteExtension takes the same lock with NOWAIT. If a publish holds it, the delete fails fast with a 409 ErrorResultException asking the user to retry, instead of removing the aggregate root (and silently deleting the just-published version) out from under the in-flight publish. On retry the new version exists, so it is no longer a delete-all. * docs: updating comment * update wording --------- Co-authored-by: Thomas Neidhart <thomas.neidhart@eclipse-foundation.org>
1 parent cbb592e commit bc4a151

9 files changed

Lines changed: 395 additions & 6 deletions

File tree

server/src/main/java/org/eclipse/openvsx/ExtensionService.java

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
import org.eclipse.openvsx.search.SearchUtilService;
2929
import org.eclipse.openvsx.util.*;
3030
import org.jobrunr.scheduling.JobRequestScheduler;
31+
import org.springframework.dao.PessimisticLockingFailureException;
3132
import org.springframework.http.HttpStatus;
3233
import org.springframework.stereotype.Service;
3334

@@ -222,9 +223,26 @@ public ResultJson deleteExtension(
222223
List<TargetPlatformVersionJson> targetVersions,
223224
UserData user
224225
) throws ErrorResultException {
226+
// Lock the extension row (NOWAIT) so a delete-all and a concurrent publish can't interleave.
227+
// Publishing takes the same lock (waiting); if the lock can not be acquired, this fails fast,
228+
// and we ask the user to retry.
229+
Extension extension;
230+
try {
231+
extension = repositories.findExtensionForUpdateNoWait(extensionName, namespaceName);
232+
} catch (PessimisticLockingFailureException e) {
233+
throw new ErrorResultException(
234+
"Extension " + NamingUtil.toExtensionId(namespaceName, extensionName)
235+
+ " can not be locked due to concurrent modification. Please try again.",
236+
HttpStatus.CONFLICT);
237+
}
238+
239+
if (extension == null) {
240+
var message = "Extension not found: " + NamingUtil.toExtensionId(namespaceName, extensionName);
241+
throw new ErrorResultException(message, HttpStatus.NOT_FOUND);
242+
}
243+
225244
var results = new ArrayList<ResultJson>();
226-
if(repositories.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user)) {
227-
var extension = repositories.findExtension(extensionName, namespaceName);
245+
if (repositories.isDeleteAllVersions(namespaceName, extensionName, targetVersions, user)) {
228246
results.add(deleteExtension(user, extension));
229247
} else {
230248
for (var targetVersion : targetVersions) {
@@ -270,7 +288,7 @@ protected ResultJson deleteExtension(UserData user, Extension extension) throws
270288
}
271289

272290
var deprecatedExtensions = repositories.findDeprecatedExtensions(extension);
273-
for(var deprecatedExtension : deprecatedExtensions) {
291+
for (var deprecatedExtension : deprecatedExtensions) {
274292
deprecatedExtension.setReplacement(null);
275293
cache.evictExtensionJsons(deprecatedExtension);
276294
}

server/src/main/java/org/eclipse/openvsx/publish/PublishExtensionVersionHandler.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,9 @@ private ExtensionVersion createExtensionVersion(ExtensionProcessor processor, Us
157157
extVersion.setPublishedWith(token);
158158
extVersion.setActive(false);
159159

160-
var extension = repositories.findExtension(extensionName, namespace);
160+
// Lock the extension row while adding a version so a concurrent delete-all serializes
161+
// against this publish (and fails fast with a retry instead of removing it under us).
162+
var extension = repositories.findExtensionForUpdate(extensionName, namespace.getName());
161163
if (extension == null) {
162164
extension = new Extension();
163165
extension.setActive(false);

server/src/main/java/org/eclipse/openvsx/repositories/ExtensionRepository.java

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import org.eclipse.openvsx.entities.UserData;
1515
import org.springframework.data.jpa.repository.Query;
1616
import org.springframework.data.repository.Repository;
17+
import org.springframework.data.repository.query.Param;
1718
import org.springframework.data.util.Streamable;
1819

1920
import java.util.Collection;
@@ -29,6 +30,26 @@ public interface ExtensionRepository extends Repository<Extension, Long> {
2930

3031
Extension findByNameIgnoreCaseAndNamespaceNameIgnoreCase(String name, String namespace);
3132

33+
// Publish takes this lock (waiting) before adding a version. We use a native FOR UPDATE rather
34+
// than @Lock(PESSIMISTIC_WRITE) on purpose: on Hibernate 6 + PostgreSQL that maps to the weaker
35+
// FOR NO KEY UPDATE, which does NOT conflict with the FOR KEY SHARE a version insert takes, so a
36+
// concurrent publish could still insert a row. FOR UPDATE conflicts with it and blocks the insert.
37+
// "OF e" scopes the lock to the extension row only (not the joined namespace) — also not
38+
// expressible via @Lock.
39+
@Query(value = "select e.* from extension e join namespace n on n.id = e.namespace_id"
40+
+ " where lower(e.name) = lower(:name) and lower(n.name) = lower(:namespace) for update of e",
41+
nativeQuery = true)
42+
Extension findByNameIgnoreCaseAndNamespaceNameIgnoreCaseForUpdate(@Param("name") String name, @Param("namespace") String namespace);
43+
44+
// Delete takes this NOWAIT variant so it fails fast (instead of blocking) when a publish holds
45+
// the lock, letting us surface a "retry" error rather than removing the extension under a publish.
46+
// Native for the same reason as above (FOR UPDATE strength + OF e); NOWAIT could also be done via
47+
// a @QueryHint lock-timeout of 0, but we keep both clauses in one explicit native query.
48+
@Query(value = "select e.* from extension e join namespace n on n.id = e.namespace_id"
49+
+ " where lower(e.name) = lower(:name) and lower(n.name) = lower(:namespace) for update of e nowait",
50+
nativeQuery = true)
51+
Extension findByNameIgnoreCaseAndNamespaceNameIgnoreCaseForUpdateNoWait(@Param("name") String name, @Param("namespace") String namespace);
52+
3253
Streamable<Extension> findByActiveTrue();
3354

3455
Streamable<Extension> findByIdIn(Collection<Long> extensionIds);

server/src/main/java/org/eclipse/openvsx/repositories/RepositoryService.java

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,15 @@ public Extension findExtension(String name, String namespace) {
208208
return extensionRepo.findByNameIgnoreCaseAndNamespaceNameIgnoreCase(name, namespace);
209209
}
210210

211+
public Extension findExtensionForUpdate(String name, String namespace) {
212+
return extensionRepo.findByNameIgnoreCaseAndNamespaceNameIgnoreCaseForUpdate(name, namespace);
213+
}
214+
215+
// Like findExtensionForUpdate but fails fast (NOWAIT) if the row is already locked by a publish.
216+
public Extension findExtensionForUpdateNoWait(String name, String namespace) {
217+
return extensionRepo.findByNameIgnoreCaseAndNamespaceNameIgnoreCaseForUpdateNoWait(name, namespace);
218+
}
219+
211220
public Streamable<Extension> findActiveExtensions(Namespace namespace) {
212221
return extensionRepo.findByNamespaceAndActiveTrueOrderByNameAsc(namespace);
213222
}

0 commit comments

Comments
 (0)