Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion com.avaloq.tools.ddk.xtext.builder/META-INF/MANIFEST.MF
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Manifest-Version: 1.0
Bundle-ManifestVersion: 2
Bundle-Name: com.avaloq.tools.ddk.xtext.builder
Bundle-SymbolicName: com.avaloq.tools.ddk.xtext.builder;singleton:=true
Bundle-Version: 17.3.1.qualifier
Bundle-Version: 17.3.2.qualifier
Bundle-Vendor: Avaloq Group AG
Require-Bundle: org.eclipse.xtext.builder,
org.eclipse.xtext.ui,
Expand Down
2 changes: 1 addition & 1 deletion com.avaloq.tools.ddk.xtext.builder/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
<version>18.0.1-SNAPSHOT</version>
<relativePath>../ddk-parent</relativePath>
</parent>
<version>17.3.1-SNAPSHOT</version>
<version>17.3.2-SNAPSHOT</version>
<groupId>com.avaloq.tools.ddk</groupId>
<artifactId>com.avaloq.tools.ddk.xtext.builder</artifactId>
<packaging>eclipse-plugin</packaging>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1101,6 +1101,11 @@ private List<URI> writeResources(final Collection<URI> toWrite, final BuildData
}
} catch (final WrappedException ex) {
pollForCancellation(monitor);
if (isAbandonedByTimeout(ex, loadOperation)) {
// the remaining resources must not silently stay unindexed
LOGGER.warn(ex.getCause().getMessage());
throw new OperationCanceledException(); // NOPMD PreserveStackTrace - the timeout is logged above
}
if (uri == null && ex instanceof LoadOperationException) {
uri = ((LoadOperationException) ex).getUri();
}
Expand Down Expand Up @@ -1330,6 +1335,19 @@ protected void queueAffectedResources(final Set<URI> allRemainingURIs, final IRe
}
}

/**
* Whether a load failure means the load operation timed out and abandoned all resources it had not delivered yet.
*
* @param exception
* the exception thrown by {@link IResourceLoader.LoadOperation#next()}, must not be {@code null}
* @param loadOperation
* the load operation that threw it, must not be {@code null}
* @return {@code true} if the operation was abandoned after a timeout
*/
public static boolean isAbandonedByTimeout(final WrappedException exception, final IResourceLoader.LoadOperation loadOperation) {
return exception instanceof LoadOperationException && exception.getCause() instanceof TimeoutException && !loadOperation.hasNext();
}

/**
* Checks if the given {@link IProgressMonitor} was cancelled.
* <p>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -216,9 +216,14 @@ public LoadResult next() {
throw new NoSuchElementException("The resource queue is empty or the execution was cancelled."); //$NON-NLS-1$
}
Triple<URI, Resource, Throwable> result = null;
boolean timedOut = false;
try {
result = resourceQueue.poll(waitTime, TimeUnit.MILLISECONDS);
toProcess--;
if (result != null) {
toProcess--;
} else {
timedOut = true;
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
Expand All @@ -227,7 +232,13 @@ public LoadResult next() {
synchronized (currentlyProcessedUris) {
currentUris = Joiner.on(", ").join(currentlyProcessedUris); //$NON-NLS-1$
}
throw new LoadOperationException(null, new TimeoutException(String.format("Resource load job didn't return a result after %d ms. Resources being currently loaded: %s", waitTime, currentUris))); //$NON-NLS-1$
String message = String.format("Resource load job didn't return a result after %d ms. Resources being currently loaded: %s", waitTime, currentUris); //$NON-NLS-1$
if (timedOut) {
// a timeout cannot be attributed to a URI and a hung load would never finish, so abandon the whole operation
cancel();
message += " The remaining loads are abandoned."; //$NON-NLS-1$
}
throw new LoadOperationException(null, new TimeoutException(message));
}

URI uri = result.getFirst();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@
import org.junit.platform.suite.api.SelectClasses;
import org.junit.platform.suite.api.Suite;

import com.avaloq.tools.ddk.xtext.builder.BuilderLoadTimeoutTest;
import com.avaloq.tools.ddk.xtext.builder.XtextBuildTriggerTest;
import com.avaloq.tools.ddk.xtext.builder.resourceloader.ParallelResourceLoaderTest;
import com.avaloq.tools.ddk.xtext.jupiter.formatter.FormatterTest;
import com.avaloq.tools.ddk.xtext.linking.AbstractFragmentProviderTest;
import com.avaloq.tools.ddk.xtext.linking.ShortFragmentProviderTest;
Expand All @@ -36,6 +38,8 @@
AbstractSelectorFragmentProviderTest.class,
ResourceDescriptionDeltaTest.class,
XtextBuildTriggerTest.class,
BuilderLoadTimeoutTest.class,
ParallelResourceLoaderTest.class,
FormatterTest.class,
QualifiedNamePatternTest.class,
BugAig1084.class,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/*******************************************************************************
* Copyright (c) 2026 Avaloq Group AG and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Avaloq Group AG - initial API and implementation
*******************************************************************************/
package com.avaloq.tools.ddk.xtext.builder;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.io.IOException;
import java.util.concurrent.TimeoutException;

import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.common.util.WrappedException;
import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperation;
import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperationException;
import org.junit.jupiter.api.Test;


/**
* Tests for {@link MonitoredClusteringBuilderState#isAbandonedByTimeout(WrappedException, LoadOperation)}.
*/
@SuppressWarnings("nls")
public class BuilderLoadTimeoutTest {

private static final TimeoutException TIMEOUT = new TimeoutException("no result");

@Test
public void timeoutWithNothingLeftIsAbandonment() {
assertTrue(MonitoredClusteringBuilderState.isAbandonedByTimeout(new LoadOperationException(null, TIMEOUT), operation(false)));
}

@Test
public void timeoutWithLoadsLeftIsNotAbandonment() {
assertFalse(MonitoredClusteringBuilderState.isAbandonedByTimeout(new LoadOperationException(null, TIMEOUT), operation(true)));
}

@Test
public void loadFailureIsNotAbandonment() {
LoadOperationException failure = new LoadOperationException(URI.createURI("platform:/resource/project/a.test"), new IOException("cannot read"));
assertFalse(MonitoredClusteringBuilderState.isAbandonedByTimeout(failure, operation(false)));
}

@Test
public void otherWrappedExceptionIsNotAbandonment() {
assertFalse(MonitoredClusteringBuilderState.isAbandonedByTimeout(new WrappedException(TIMEOUT), operation(false)));
}

private static LoadOperation operation(final boolean hasNext) {
LoadOperation operation = mock(LoadOperation.class);
when(operation.hasNext()).thenReturn(hasNext);
return operation;
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
/*******************************************************************************
* Copyright (c) 2026 Avaloq Group AG and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Avaloq Group AG - initial API and implementation
*******************************************************************************/
package com.avaloq.tools.ddk.xtext.builder.resourceloader;

import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
import static org.junit.jupiter.api.Assertions.assertSame;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;

import java.io.IOException;
import java.util.Collections;
import java.util.List;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Function;

import org.eclipse.emf.common.util.URI;
import org.eclipse.emf.common.util.WrappedException;
import org.eclipse.emf.ecore.resource.Resource;
import org.eclipse.emf.ecore.resource.ResourceSet;
import org.eclipse.emf.ecore.resource.impl.ResourceImpl;
import org.eclipse.emf.ecore.resource.impl.ResourceSetImpl;
import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperation;
import org.eclipse.xtext.builder.resourceloader.IResourceLoader.LoadOperationException;
import org.eclipse.xtext.builder.resourceloader.IResourceLoader.Sorter;
import org.eclipse.xtext.resource.IResourceServiceProvider;
import org.eclipse.xtext.resource.persistence.SourceLevelURIsAdapter;
import org.eclipse.xtext.ui.resource.IResourceSetProvider;
import org.junit.jupiter.api.Test;

import com.google.common.util.concurrent.Uninterruptibles;
import com.google.inject.Guice;


/**
* Tests for {@link ParallelResourceLoader}.
*/
@SuppressWarnings("nls")
public class ParallelResourceLoaderTest {

private static final URI SLOW_URI = URI.createURI("platform:/resource/project/slow.test");
private static final URI OTHER_URI = URI.createURI("platform:/resource/project/other.test");
private static final long TIMEOUT_MILLIS = 50;
private static final long GENEROUS_TIMEOUT_MILLIS = TimeUnit.SECONDS.toMillis(10);
private static final long SETTLE_MILLIS = 200;
/** Unbounded result queue, and the synchronous hand-off used in production. */
private static final int[] QUEUE_SIZES = {-1, 0};

@Test
public void timeoutAbandonsEveryOutstandingLoad() {
for (int queueSize : QUEUE_SIZES) {
CountDownLatch loadReleased = new CountDownLatch(1);
Set<URI> loaded = ConcurrentHashMap.newKeySet();
LoadOperation operation = load(List.of(SLOW_URI, OTHER_URI), queueSize, TIMEOUT_MILLIS, uri -> {
loaded.add(uri);
Uninterruptibles.awaitUninterruptibly(loadReleased);
return new ResourceImpl(uri);
});
try {
assertTimesOut(operation);
assertFalse(operation.hasNext(), "one timeout must abandon all outstanding loads, not just one");

loadReleased.countDown();
Uninterruptibles.sleepUninterruptibly(SETTLE_MILLIS, TimeUnit.MILLISECONDS);
assertFalse(loaded.contains(OTHER_URI), "queued loads must not start after the operation was abandoned");
} finally {
loadReleased.countDown();
operation.cancel();
}
}
}

@Test
public void failedLoadDoesNotAbandonTheOperation() {
for (int queueSize : QUEUE_SIZES) {
LoadOperation operation = load(List.of(SLOW_URI, OTHER_URI), queueSize, GENEROUS_TIMEOUT_MILLIS, uri -> {
if (SLOW_URI.equals(uri)) {
throw new WrappedException(new IOException("cannot read"));
}
return new ResourceImpl(uri);
});
try {
LoadOperationException failure = assertThrows(LoadOperationException.class, operation::next);
assertSame(SLOW_URI, failure.getUri());
assertInstanceOf(IOException.class, failure.getCause());
assertTrue(operation.hasNext(), "a failed load must not abandon the remaining loads");
assertSame(OTHER_URI, operation.next().getResource().getURI());
} finally {
operation.cancel();
}
}
}

@Test
public void resultWithinTimeoutIsDelivered() {
for (int queueSize : QUEUE_SIZES) {
LoadOperation operation = load(List.of(SLOW_URI), queueSize, GENEROUS_TIMEOUT_MILLIS, ResourceImpl::new);
try {
assertSame(SLOW_URI, operation.next().getResource().getURI());
assertFalse(operation.hasNext(), "the single result has been delivered");
} finally {
operation.cancel();
}
}
}

private static LoadOperation load(final List<URI> uris, final int queueSize, final long timeoutMillis, final Function<URI, Resource> loadFunction) {
ParallelResourceLoader loader = new ParallelResourceLoader(resourceSetProvider(), new Sorter.NoSorting(), 1, queueSize) {
@Override
protected Resource loadResource(final URI uri, final ResourceSet localResourceSet, final ResourceSet parentResourceSet) {
return loadFunction.apply(uri);
}
};
IResourceServiceProvider.Registry registry = mock(IResourceServiceProvider.Registry.class);
Guice.createInjector(binder -> binder.bind(IResourceServiceProvider.Registry.class).toInstance(registry)).injectMembers(loader);
loader.setTimeout(timeoutMillis, TimeUnit.MILLISECONDS);

ResourceSet parent = new ResourceSetImpl();
SourceLevelURIsAdapter.setSourceLevelUris(parent, Collections.emptySet());
LoadOperation operation = loader.create(parent, null);
operation.load(uris);
return operation;
}

private static void assertTimesOut(final LoadOperation operation) {
LoadOperationException timeout = assertThrows(LoadOperationException.class, operation::next);
assertInstanceOf(TimeoutException.class, timeout.getCause());
}

private static IResourceSetProvider resourceSetProvider() {
IResourceSetProvider provider = mock(IResourceSetProvider.class);
when(provider.get(any())).thenAnswer(invocation -> new ResourceSetImpl());
return provider;
}

}