diff --git a/doc/throttle-debounce.png b/doc/throttle-debounce.png
new file mode 100644
index 000000000..9d26208a7
Binary files /dev/null and b/doc/throttle-debounce.png differ
diff --git a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java
index 9a6ec39d7..835dd77e5 100644
--- a/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java
+++ b/src/main/java/cz/cvut/kbss/termit/config/AppConfig.java
@@ -19,14 +19,17 @@
import cz.cvut.kbss.termit.util.AsyncExceptionHandler;
import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
+import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.EnableAspectJAutoProxy;
import org.springframework.context.annotation.EnableMBeanExport;
+import org.springframework.context.annotation.ImportResource;
import org.springframework.context.annotation.aspectj.EnableSpringConfigured;
import org.springframework.retry.annotation.EnableRetry;
import org.springframework.scheduling.annotation.AsyncConfigurer;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler;
@Configuration
@EnableMBeanExport
@@ -35,10 +38,24 @@
@EnableAsync
@EnableScheduling
@EnableRetry
+@ImportResource("classpath*:spring-aop.xml")
public class AppConfig implements AsyncConfigurer {
@Override
public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
return new AsyncExceptionHandler();
}
+
+ /**
+ * This thread pool is responsible for executing long-running tasks in the application.
+ */
+ @Bean(destroyMethod = "destroy")
+ public ThreadPoolTaskScheduler longRunningTaskScheduler(cz.cvut.kbss.termit.util.Configuration config) {
+ ThreadPoolTaskScheduler threadPoolTaskScheduler = new ThreadPoolTaskScheduler();
+ threadPoolTaskScheduler.setPoolSize(config.getAsyncThreadCount());
+ threadPoolTaskScheduler.setThreadNamePrefix("TermItScheduler-");
+ threadPoolTaskScheduler.setWaitForTasksToCompleteOnShutdown(true);
+ threadPoolTaskScheduler.setRemoveOnCancelPolicy(true);
+ return threadPoolTaskScheduler;
+ }
}
diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
index 61781c11a..eaa03f2e8 100644
--- a/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
+++ b/src/main/java/cz/cvut/kbss/termit/config/WebAppConfig.java
@@ -62,19 +62,13 @@
@Configuration
public class WebAppConfig implements WebMvcConfigurer {
- private final cz.cvut.kbss.termit.util.Configuration.Repository config;
+ private final cz.cvut.kbss.termit.util.Configuration config;
@Value("${application.version:development}")
private String version;
public WebAppConfig(cz.cvut.kbss.termit.util.Configuration config) {
- this.config = config.getRepository();
- }
-
- @Bean(name = "objectMapper")
- @Primary
- public ObjectMapper objectMapper() {
- return createJsonObjectMapper();
+ this.config = config;
}
/**
@@ -99,11 +93,6 @@ public static ObjectMapper createJsonObjectMapper() {
return objectMapper;
}
- @Bean(name = "jsonLdMapper")
- public ObjectMapper jsonLdObjectMapper() {
- return createJsonLdObjectMapper();
- }
-
/**
* Creates an {@link ObjectMapper} for processing JSON-LD using the JB4JSON-LD library.
*
@@ -119,9 +108,21 @@ public static ObjectMapper createJsonLdObjectMapper() {
jsonLdModule.configure(cz.cvut.kbss.jsonld.ConfigParam.SCAN_PACKAGE, "cz.cvut.kbss.termit");
jsonLdModule.configure(SerializationConstants.FORM, SerializationConstants.FORM_COMPACT_WITH_CONTEXT);
mapper.registerModule(jsonLdModule);
+ mapper.registerModule(new JavaTimeModule());
return mapper;
}
+ @Bean(name = "objectMapper")
+ @Primary
+ public ObjectMapper objectMapper() {
+ return createJsonObjectMapper();
+ }
+
+ @Bean(name = "jsonLdMapper")
+ public ObjectMapper jsonLdObjectMapper() {
+ return createJsonLdObjectMapper();
+ }
+
/**
* Register the proxy for SPARQL endpoint.
*
@@ -133,10 +134,11 @@ public ServletWrappingController sparqlEndpointController() throws Exception {
controller.setServletClass(AdjustedUriTemplateProxyServlet.class);
controller.setBeanName("sparqlEndpointProxyServlet");
final Properties p = new Properties();
- p.setProperty("targetUri", config.getUrl());
+ final cz.cvut.kbss.termit.util.Configuration.Repository repository = config.getRepository();
+ p.setProperty("targetUri", repository.getUrl());
p.setProperty("log", "false");
- p.setProperty(ConfigParam.REPO_USERNAME.toString(), config.getUsername() != null ? config.getUsername() : "");
- p.setProperty(ConfigParam.REPO_PASSWORD.toString(), config.getPassword() != null ? config.getPassword() : "");
+ p.setProperty(ConfigParam.REPO_USERNAME.toString(), repository.getUsername() != null ? repository.getUsername() : "");
+ p.setProperty(ConfigParam.REPO_PASSWORD.toString(), repository.getPassword() != null ? repository.getPassword() : "");
controller.setInitParameters(p);
controller.afterPropertiesSet();
return controller;
@@ -147,7 +149,7 @@ public SimpleUrlHandlerMapping sparqlQueryControllerMapping() throws Exception {
SimpleUrlHandlerMapping mapping = new SimpleUrlHandlerMapping();
mapping.setOrder(0);
final Map urlMap = Collections.singletonMap(Constants.REST_MAPPING_PATH + "/query",
- sparqlEndpointController());
+ sparqlEndpointController());
mapping.setUrlMap(urlMap);
return mapping;
}
@@ -193,10 +195,10 @@ public FilterRegistrationBean mdcFilter() {
@Bean
public OpenAPI customOpenAPI() {
return new OpenAPI().components(new Components().addSecuritySchemes("bearer-key",
- new SecurityScheme().type(
- SecurityScheme.Type.HTTP)
- .scheme("bearer")
- .bearerFormat("JWT")))
+ new SecurityScheme().type(
+ SecurityScheme.Type.HTTP)
+ .scheme("bearer")
+ .bearerFormat("JWT")))
.info(new Info().title("TermIt REST API").description("TermIt REST API definition.")
.version(version));
}
diff --git a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java
index ceefa1273..3f5cdb08d 100644
--- a/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java
+++ b/src/main/java/cz/cvut/kbss/termit/config/WebSocketMessageBrokerConfig.java
@@ -4,19 +4,17 @@
import cz.cvut.kbss.termit.util.Constants;
import cz.cvut.kbss.termit.websocket.handler.StompExceptionHandler;
import cz.cvut.kbss.termit.websocket.handler.WebSocketExceptionHandler;
-import cz.cvut.kbss.termit.websocket.handler.WebSocketMessageWithHeadersValueHandler;
-import org.jetbrains.annotations.NotNull;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Lazy;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
+import org.springframework.lang.NonNull;
import org.springframework.messaging.Message;
import org.springframework.messaging.converter.MappingJackson2MessageConverter;
import org.springframework.messaging.converter.MessageConverter;
import org.springframework.messaging.converter.StringMessageConverter;
import org.springframework.messaging.handler.invocation.HandlerMethodArgumentResolver;
-import org.springframework.messaging.handler.invocation.HandlerMethodReturnValueHandler;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.messaging.simp.config.ChannelRegistration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
@@ -88,17 +86,12 @@ public void addArgumentResolvers(List argumentRes
* @see Spring security source
*/
@Override
- public void configureClientInboundChannel(@NotNull ChannelRegistration registration) {
+ public void configureClientInboundChannel(@NonNull ChannelRegistration registration) {
AuthorizationChannelInterceptor interceptor = new AuthorizationChannelInterceptor(messageAuthorizationManager);
interceptor.setAuthorizationEventPublisher(new SpringAuthorizationEventPublisher(context));
registration.interceptors(webSocketJwtAuthorizationInterceptor, new SecurityContextChannelInterceptor(), interceptor);
}
- @Override
- public void addReturnValueHandlers(List returnValueHandlers) {
- returnValueHandlers.add(new WebSocketMessageWithHeadersValueHandler(simpMessagingTemplate));
- }
-
@Override
public void registerStompEndpoints(StompEndpointRegistry registry) {
registry.addEndpoint("/ws").setAllowedOrigins(allowedOrigins.split(","));
diff --git a/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java
new file mode 100644
index 000000000..d8d7caa40
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/event/FileTextAnalysisFinishedEvent.java
@@ -0,0 +1,23 @@
+package cz.cvut.kbss.termit.event;
+
+import cz.cvut.kbss.termit.model.resource.File;
+import org.springframework.lang.NonNull;
+
+import java.net.URI;
+
+/**
+ * Indicates that text analysis of a file was finished
+ */
+public class FileTextAnalysisFinishedEvent extends VocabularyEvent {
+
+ private final URI fileUri;
+
+ public FileTextAnalysisFinishedEvent(Object source, @NonNull File file) {
+ super(source, file.getDocument().getVocabulary());
+ this.fileUri = file.getUri();
+ }
+
+ public URI getFileUri() {
+ return fileUri;
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java
new file mode 100644
index 000000000..fd3cf7af1
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/event/LongRunningTaskChangedEvent.java
@@ -0,0 +1,22 @@
+package cz.cvut.kbss.termit.event;
+
+import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskStatus;
+import org.springframework.context.ApplicationEvent;
+import org.springframework.lang.NonNull;
+
+/**
+ * Indicates a status change of a long-running task.
+ */
+public class LongRunningTaskChangedEvent extends ApplicationEvent {
+
+ private final LongRunningTaskStatus status;
+
+ public LongRunningTaskChangedEvent(@NonNull Object source, final @NonNull LongRunningTaskStatus status) {
+ super(source);
+ this.status = status;
+ }
+
+ public @NonNull LongRunningTaskStatus getStatus() {
+ return status;
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java
new file mode 100644
index 000000000..748d7a075
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/event/TermDefinitionTextAnalysisFinishedEvent.java
@@ -0,0 +1,22 @@
+package cz.cvut.kbss.termit.event;
+
+import cz.cvut.kbss.termit.model.AbstractTerm;
+import org.springframework.lang.NonNull;
+
+import java.net.URI;
+
+/**
+ * Indicates that a text analysis of a term definition was finished
+ */
+public class TermDefinitionTextAnalysisFinishedEvent extends VocabularyEvent {
+ private final URI termUri;
+
+ public TermDefinitionTextAnalysisFinishedEvent(@NonNull Object source, @NonNull AbstractTerm term) {
+ super(source, term.getVocabulary());
+ this.termUri = term.getUri();
+ }
+
+ public URI getTermUri() {
+ return termUri;
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java
similarity index 73%
rename from src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java
rename to src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java
index 8eed780fd..324c1d45c 100644
--- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModified.java
+++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyContentModifiedEvent.java
@@ -17,7 +17,7 @@
*/
package cz.cvut.kbss.termit.event;
-import org.springframework.context.ApplicationEvent;
+import org.springframework.lang.NonNull;
import java.net.URI;
@@ -26,16 +26,9 @@
*
* This typically means a term is added, removed or modified. Modification of vocabulary metadata themselves is not considered here.
*/
-public class VocabularyContentModified extends ApplicationEvent {
+public class VocabularyContentModifiedEvent extends VocabularyEvent {
- private final URI vocabularyIri;
-
- public VocabularyContentModified(Object source, URI vocabularyIri) {
- super(source);
- this.vocabularyIri = vocabularyIri;
- }
-
- public URI getVocabularyIri() {
- return vocabularyIri;
+ public VocabularyContentModifiedEvent(@NonNull Object source, @NonNull URI vocabularyIri) {
+ super(source, vocabularyIri);
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java
index e1da1aeab..704169105 100644
--- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java
+++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyCreatedEvent.java
@@ -17,14 +17,16 @@
*/
package cz.cvut.kbss.termit.event;
-import org.springframework.context.ApplicationEvent;
+import org.springframework.lang.NonNull;
+
+import java.net.URI;
/**
* Indicates that a vocabulary has been created.
*/
-public class VocabularyCreatedEvent extends ApplicationEvent {
+public class VocabularyCreatedEvent extends VocabularyEvent {
- public VocabularyCreatedEvent(Object source) {
- super(source);
+ public VocabularyCreatedEvent(@NonNull Object source, @NonNull URI vocabularyIri) {
+ super(source, vocabularyIri);
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java
new file mode 100644
index 000000000..133afe2f5
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyEvent.java
@@ -0,0 +1,28 @@
+package cz.cvut.kbss.termit.event;
+
+import org.springframework.context.ApplicationEvent;
+import org.springframework.lang.NonNull;
+
+import java.net.URI;
+import java.util.Objects;
+
+/**
+ * Base class for vocabulary related events
+ */
+public abstract class VocabularyEvent extends ApplicationEvent {
+ protected final URI vocabularyIri;
+
+ protected VocabularyEvent(@NonNull Object source, @NonNull URI vocabularyIri) {
+ super(source);
+ Objects.requireNonNull(vocabularyIri);
+ this.vocabularyIri = vocabularyIri;
+ }
+
+ /**
+ * The identifier of the vocabulary to which this event is bound
+ * @return vocabulary IRI
+ */
+ public URI getVocabularyIri() {
+ return vocabularyIri;
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java
new file mode 100644
index 000000000..a5af0bbe8
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyValidationFinishedEvent.java
@@ -0,0 +1,49 @@
+package cz.cvut.kbss.termit.event;
+
+import cz.cvut.kbss.termit.model.validation.ValidationResult;
+import org.springframework.lang.NonNull;
+
+import java.net.URI;
+import java.util.ArrayList;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+
+/**
+ * Indicates that validation for a set of vocabularies was finished.
+ */
+public class VocabularyValidationFinishedEvent extends VocabularyEvent {
+
+ /**
+ * Vocabulary closure of {@link #vocabularyIri}.
+ * IRIs of vocabularies that are imported by {@link #vocabularyIri} and were part of the validation.
+ */
+ private final List vocabularyIris;
+
+ private final List validationResults;
+
+ /**
+ * @param source the source of the event
+ * @param originVocabularyIri Vocabulary closure of {@link #vocabularyIri}.
+ * @param vocabularyIris IRI of the vocabulary on which the validation was triggered.
+ * @param validationResults results of the validation
+ */
+ public VocabularyValidationFinishedEvent(@NonNull Object source, @NonNull URI originVocabularyIri,
+ @NonNull Collection vocabularyIris,
+ @NonNull List validationResults) {
+ super(source, originVocabularyIri);
+ // defensive copy
+ this.vocabularyIris = new ArrayList<>(vocabularyIris);
+ this.validationResults = new ArrayList<>(validationResults);
+ }
+
+ @NonNull
+ public List getVocabularyIris() {
+ return Collections.unmodifiableList(vocabularyIris);
+ }
+
+ @NonNull
+ public List getValidationResults() {
+ return Collections.unmodifiableList(validationResults);
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java
index 3fed1f16e..0e0b6503a 100644
--- a/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java
+++ b/src/main/java/cz/cvut/kbss/termit/event/VocabularyWillBeRemovedEvent.java
@@ -1,21 +1,15 @@
package cz.cvut.kbss.termit.event;
-import org.springframework.context.ApplicationEvent;
+import org.springframework.lang.NonNull;
import java.net.URI;
/**
* Indicates that a Vocabulary will be removed
*/
-public class VocabularyWillBeRemovedEvent extends ApplicationEvent {
- private final URI vocabulary;
+public class VocabularyWillBeRemovedEvent extends VocabularyEvent {
- public VocabularyWillBeRemovedEvent(Object source, URI vocabulary) {
- super(source);
- this.vocabulary = vocabulary;
- }
-
- public URI getVocabulary() {
- return vocabulary;
+ public VocabularyWillBeRemovedEvent(@NonNull Object source, @NonNull URI vocabularyIri) {
+ super(source, vocabularyIri);
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java b/src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java
new file mode 100644
index 000000000..2f8270bf7
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/exception/ThrottleAspectException.java
@@ -0,0 +1,17 @@
+package cz.cvut.kbss.termit.exception;
+
+/**
+ * Indicates wrong usage of {@link cz.cvut.kbss.termit.util.throttle.Throttle} annotation.
+ *
+ * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect
+ */
+public class ThrottleAspectException extends TermItException {
+
+ public ThrottleAspectException(String message) {
+ super(message);
+ }
+
+ public ThrottleAspectException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java b/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java
index 06c8acd58..2198e44f3 100644
--- a/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java
+++ b/src/main/java/cz/cvut/kbss/termit/model/Vocabulary.java
@@ -18,6 +18,7 @@
package cz.cvut.kbss.termit.model;
import com.fasterxml.jackson.annotation.JsonIgnore;
+import cz.cvut.kbss.jopa.exception.LazyLoadingException;
import cz.cvut.kbss.jopa.model.MultilingualString;
import cz.cvut.kbss.jopa.model.annotations.CascadeType;
import cz.cvut.kbss.jopa.model.annotations.FetchType;
@@ -236,13 +237,20 @@ public int hashCode() {
@Override
public String toString() {
- return "Vocabulary{" +
- getLabel() +
- " " + Utils.uriToString(getUri()) +
- ", glossary=" + glossary +
- (importedVocabularies != null ?
- ", importedVocabularies = [" + importedVocabularies.stream().map(Utils::uriToString).collect(
- Collectors.joining(", ")) + "]" : "") +
- '}';
+ String result = "Vocabulary{"+
+ getLabel() + " "
+ + Utils.uriToString(getUri());
+ try {
+ result += ", glossary=" + glossary;
+ if (importedVocabularies != null) {
+ result +=", importedVocabularies = [" +
+ importedVocabularies.stream().map(Utils::uriToString)
+ .collect(Collectors.joining(", ")) + "]";
+ }
+ } catch (LazyLoadingException e) {
+ // persistent context not available
+ }
+
+ return result;
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java b/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java
index 331bc461b..ab4f30f9d 100644
--- a/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java
+++ b/src/main/java/cz/cvut/kbss/termit/model/validation/ValidationResult.java
@@ -26,12 +26,13 @@
import cz.cvut.kbss.termit.model.Term;
import org.topbraid.shacl.vocabulary.SH;
+import java.io.Serializable;
import java.net.URI;
import java.util.Objects;
@NonEntity
@OWLClass(iri = SH.BASE_URI + "ValidationResult")
-public class ValidationResult {
+public class ValidationResult implements Serializable {
@Id(generated = true)
private URI id;
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java
index 34ded7f67..50d138f16 100644
--- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java
+++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermDao.java
@@ -28,7 +28,7 @@
import cz.cvut.kbss.termit.event.AssetPersistEvent;
import cz.cvut.kbss.termit.event.AssetUpdateEvent;
import cz.cvut.kbss.termit.event.EvictCacheEvent;
-import cz.cvut.kbss.termit.event.VocabularyContentModified;
+import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent;
import cz.cvut.kbss.termit.exception.PersistenceException;
import cz.cvut.kbss.termit.model.AbstractTerm;
import cz.cvut.kbss.termit.model.Term;
@@ -174,7 +174,7 @@ public void persist(Term entity, Vocabulary vocabulary) {
entity.setVocabulary(null); // This is inferred
em.persist(entity, descriptorFactory.termDescriptor(vocabulary));
evictCachedSubTerms(Collections.emptySet(), entity.getParentTerms());
- eventPublisher.publishEvent(new VocabularyContentModified(this, vocabulary.getUri()));
+ eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, vocabulary.getUri()));
eventPublisher.publishEvent(new AssetPersistEvent(this, entity));
} catch (RuntimeException e) {
throw new PersistenceException(e);
@@ -194,7 +194,7 @@ public Term update(Term entity) {
eventPublisher.publishEvent(new AssetUpdateEvent(this, entity));
evictCachedSubTerms(original.getParentTerms(), entity.getParentTerms());
final Term result = em.merge(entity, descriptorFactory.termDescriptor(entity));
- eventPublisher.publishEvent(new VocabularyContentModified(this, original.getVocabulary()));
+ eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, original.getVocabulary()));
return result;
} catch (RuntimeException e) {
throw new PersistenceException(e);
@@ -790,7 +790,7 @@ public List findAllUnused(Vocabulary vocabulary) {
public void remove(Term entity) {
super.remove(entity);
evictCachedSubTerms(entity.getParentTerms(), Collections.emptySet());
- eventPublisher.publishEvent(new VocabularyContentModified(this, entity.getVocabulary()));
+ eventPublisher.publishEvent(new VocabularyContentModifiedEvent(this, entity.getVocabulary()));
}
@Override
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java
index 373991a1e..1b01a46d3 100644
--- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java
+++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/TermOccurrenceDao.java
@@ -29,9 +29,9 @@
import cz.cvut.kbss.termit.model.Asset;
import cz.cvut.kbss.termit.model.Term;
import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
-import cz.cvut.kbss.termit.persistence.dao.util.ScheduledContextRemover;
import cz.cvut.kbss.termit.persistence.dao.util.SparqlResultToTermOccurrenceMapper;
import cz.cvut.kbss.termit.util.Configuration;
+import cz.cvut.kbss.termit.util.Utils;
import cz.cvut.kbss.termit.util.Vocabulary;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -80,12 +80,9 @@ public class TermOccurrenceDao extends BaseDao {
private final Configuration.Persistence config;
- private final ScheduledContextRemover contextRemover;
-
- public TermOccurrenceDao(EntityManager em, Configuration config, ScheduledContextRemover contextRemover) {
+ public TermOccurrenceDao(EntityManager em, Configuration config) {
super(TermOccurrence.class, em);
this.config = config.getPersistence();
- this.contextRemover = contextRemover;
}
/**
@@ -258,12 +255,12 @@ public void removeAll(Asset> target) {
Objects.requireNonNull(target);
final URI sourceContext = TermOccurrence.resolveContext(target.getUri());
- final URI targetContext = URI.create(sourceContext + "-for-removal-" + System.currentTimeMillis());
- em.createNativeQuery("MOVE GRAPH ?g TO ?targetContext")
- .setParameter("g", sourceContext)
- .setParameter("targetContext", targetContext)
- .executeUpdate();
- contextRemover.scheduleForRemoval(targetContext);
+ LOG.debug("Removing all occurrences from {}", sourceContext);
+ em.createNativeQuery("DROP GRAPH ?context")
+ .setParameter("context", sourceContext)
+ .executeUpdate();
+ LOG.atDebug().setMessage("Removed all occurrences from {}")
+ .addArgument(() -> Utils.uriToString(sourceContext)).log();
}
/**
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java
index 1f04fe548..21e5233f4 100644
--- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java
+++ b/src/main/java/cz/cvut/kbss/termit/persistence/dao/VocabularyDao.java
@@ -45,6 +45,7 @@
import cz.cvut.kbss.termit.service.snapshot.SnapshotProvider;
import cz.cvut.kbss.termit.util.Configuration;
import cz.cvut.kbss.termit.util.Utils;
+import cz.cvut.kbss.termit.util.throttle.CacheableFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -144,17 +145,17 @@ public Vocabulary getReference(URI id) {
/**
* Gets identifiers of all vocabularies imported by the specified vocabulary, including transitively imported ones.
*
- * @param entity Base vocabulary, whose imports should be retrieved
+ * @param vocabularyIri Identifier of base vocabulary, whose imports should be retrieved
* @return Collection of (transitively) imported vocabularies
*/
- public Collection getTransitivelyImportedVocabularies(Vocabulary entity) {
- Objects.requireNonNull(entity);
+ public Collection getTransitivelyImportedVocabularies(URI vocabularyIri) {
+ Objects.requireNonNull(vocabularyIri);
try {
return em.createNativeQuery("SELECT DISTINCT ?imported WHERE {" +
"?x ?imports+ ?imported ." +
"}", URI.class)
.setParameter("imports", URI.create(cz.cvut.kbss.termit.util.Vocabulary.s_p_importuje_slovnik))
- .setParameter("x", entity.getUri()).getResultList();
+ .setParameter("x", vocabularyIri).getResultList();
} catch (RuntimeException e) {
throw new PersistenceException(e);
}
@@ -357,11 +358,11 @@ public void refreshLastModified(RefreshLastModifiedEvent event) {
}
@Transactional
- public List validateContents(Vocabulary voc) {
+ public CacheableFuture> validateContents(URI vocabulary) {
final VocabularyContentValidator validator = context.getBean(VocabularyContentValidator.class);
- final Collection importClosure = getTransitivelyImportedVocabularies(voc);
- importClosure.add(voc.getUri());
- return validator.validate(importClosure);
+ final Collection importClosure = getTransitivelyImportedVocabularies(vocabulary);
+ importClosure.add(vocabulary);
+ return validator.validate(vocabulary, importClosure);
}
/**
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java b/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java
deleted file mode 100644
index 326fe0ab0..000000000
--- a/src/main/java/cz/cvut/kbss/termit/persistence/dao/util/ScheduledContextRemover.java
+++ /dev/null
@@ -1,65 +0,0 @@
-package cz.cvut.kbss.termit.persistence.dao.util;
-
-import cz.cvut.kbss.jopa.model.EntityManager;
-import cz.cvut.kbss.termit.util.Utils;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.lang.NonNull;
-import org.springframework.scheduling.annotation.Scheduled;
-import org.springframework.stereotype.Component;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.net.URI;
-import java.util.HashSet;
-import java.util.Objects;
-import java.util.Set;
-import java.util.concurrent.TimeUnit;
-
-/**
- * Drops registered repository contexts at scheduled moments.
- *
- * This allows to move time-consuming removal of repository contexts containing a lot of data to times of low system
- * activity.
- */
-@Component
-public class ScheduledContextRemover {
-
- private static final Logger LOG = LoggerFactory.getLogger(ScheduledContextRemover.class);
-
- private final EntityManager em;
-
- private final Set contextsToRemove = new HashSet<>();
-
- public ScheduledContextRemover(EntityManager em) {
- this.em = em;
- }
-
- /**
- * Schedules the specified context identifier for removal at the next execution of the context cleanup.
- *
- * @param contextUri Identifier of the context to remove
- * @see #runContextRemoval()
- */
- public synchronized void scheduleForRemoval(@NonNull URI contextUri) {
- LOG.debug("Scheduling context {} for removal.", Utils.uriToString(contextUri));
- contextsToRemove.add(Objects.requireNonNull(contextUri));
- }
-
- /**
- * Runs the removal of the registered repository contexts.
- *
- * This method is scheduled and should not be invoked manually.
- *
- * @see #scheduleForRemoval(URI)
- */
- @Transactional
- @Scheduled(fixedRate = 1, timeUnit = TimeUnit.MINUTES)
- public synchronized void runContextRemoval() {
- LOG.trace("Running scheduled repository context removal.");
- contextsToRemove.forEach(g -> {
- LOG.trace("Dropping repository context {}.", Utils.uriToString(g));
- em.createNativeQuery("DROP GRAPH ?g").setParameter("g", g).executeUpdate();
- });
- contextsToRemove.clear();
- }
-}
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java
index eb757ccfd..1d6cfe406 100644
--- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java
+++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/ResultCachingValidator.java
@@ -17,24 +17,34 @@
*/
package cz.cvut.kbss.termit.persistence.validation;
-import cz.cvut.kbss.termit.event.VocabularyContentModified;
+import cz.cvut.kbss.termit.event.EvictCacheEvent;
+import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent;
+import cz.cvut.kbss.termit.event.VocabularyCreatedEvent;
+import cz.cvut.kbss.termit.event.VocabularyEvent;
+import cz.cvut.kbss.termit.exception.TermItException;
import cz.cvut.kbss.termit.model.validation.ValidationResult;
+import cz.cvut.kbss.termit.util.throttle.Throttle;
+import cz.cvut.kbss.termit.util.throttle.ThrottledFuture;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Lookup;
import org.springframework.context.annotation.Primary;
import org.springframework.context.annotation.Profile;
import org.springframework.context.event.EventListener;
+import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
import java.net.URI;
-import java.util.ArrayList;
import java.util.Collection;
-import java.util.HashSet;
+import java.util.Collections;
+import java.util.HashMap;
import java.util.List;
import java.util.Map;
+import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutionException;
@Component("cachingValidator")
@Primary
@@ -43,12 +53,72 @@ public class ResultCachingValidator implements VocabularyContentValidator {
private static final Logger LOG = LoggerFactory.getLogger(ResultCachingValidator.class);
- private final Map, List> validationCache = new ConcurrentHashMap<>();
+ /**
+ * Map of origin vocabulary IRI to vocabulary iri closure of imported vocabularies.
+ * When the record is missing, the cache is considered as dirty.
+ */
+ private final Map> vocabularyClosure = new ConcurrentHashMap<>();
+ private final Map> validationCache = new HashMap<>();
+
+ /**
+ * @return true when the cache contents are dirty and should be refreshed; false otherwise.
+ */
+ public boolean isNotDirty(@NonNull URI originVocabularyIri) {
+ return vocabularyClosure.containsKey(originVocabularyIri);
+ }
+
+ private Optional> getCached(@NonNull URI originVocabularyIri) {
+ synchronized (validationCache) {
+ return Optional.ofNullable(validationCache.get(originVocabularyIri));
+ }
+ }
+
+ @Throttle(value = "{#originVocabularyIri}", name="vocabularyValidation")
+ @Transactional
@Override
- public List validate(Collection vocabularyIris) {
- final Set copy = new HashSet<>(vocabularyIris); // Defensive copy
- return new ArrayList<>(validationCache.computeIfAbsent(copy, uris -> getValidator().validate(vocabularyIris)));
+ @NonNull
+ public ThrottledFuture> validate(@NonNull URI originVocabularyIri, @NonNull Collection vocabularyIris) {
+ final Set iris = Set.copyOf(vocabularyIris);
+
+ if (iris.isEmpty()) {
+ LOG.warn("Validation of empty IRI list was requested for {}", originVocabularyIri);
+ return ThrottledFuture.done(List.of());
+ }
+
+ Optional> cached = getCached(originVocabularyIri);
+ if (isNotDirty(originVocabularyIri) && cached.isPresent()) {
+ return ThrottledFuture.done(cached.get());
+ }
+
+ return ThrottledFuture.of(() -> runValidation(originVocabularyIri, iris)).setCachedResult(cached.orElse(null));
+ }
+
+ @NonNull
+ private Collection runValidation(@NonNull URI originVocabularyIri, @NonNull final Set iris) {
+ Optional> cached = getCached(originVocabularyIri);
+ if (isNotDirty(originVocabularyIri) && cached.isPresent()) {
+ return cached.get();
+ }
+
+ final Collection results;
+ try {
+ // executes real validation
+ // get is safe here as long as we are on throttled thread from #validate method
+ results = getValidator().validate(originVocabularyIri, iris).get();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TermItException(e);
+ } catch (ExecutionException e) {
+ throw new TermItException(e.getCause());
+ }
+
+ synchronized (validationCache) {
+ vocabularyClosure.put(originVocabularyIri, Collections.unmodifiableCollection(iris));
+ validationCache.put(originVocabularyIri, Collections.unmodifiableCollection(results));
+ }
+
+ return results;
}
@Lookup
@@ -56,9 +126,34 @@ Validator getValidator() {
return null; // Will be replaced by Spring
}
- @EventListener
- public void evictCache(VocabularyContentModified event) {
- LOG.debug("Vocabulary content modified, evicting validation result cache.");
- validationCache.clear();
+ /**
+ * Marks cache related to the vocabulary from the event as dirty
+ */
+ @EventListener({VocabularyContentModifiedEvent.class, VocabularyCreatedEvent.class})
+ public void markCacheDirty(VocabularyEvent event) {
+ LOG.debug("Vocabulary content modified, marking cache as dirty for {}.", event.getVocabularyIri());
+ // marked as dirty for specified vocabulary
+ vocabularyClosure.remove(event.getVocabularyIri());
+ // now mark all vocabularies importing modified vocabulary as dirty too
+ synchronized (validationCache) {
+ vocabularyClosure.keySet().forEach(originVocabularyIri -> {
+ final Collection closure = vocabularyClosure.get(originVocabularyIri);
+ if (closure != null && closure.contains(event.getVocabularyIri())) {
+ vocabularyClosure.remove(originVocabularyIri);
+ }
+ });
+ if (event instanceof VocabularyCreatedEvent) {
+ validationCache.remove(event.getVocabularyIri());
+ }
+ }
+ }
+
+ @EventListener(EvictCacheEvent.class)
+ public void evictCache() {
+ LOG.debug("Validation cache cleared");
+ synchronized (validationCache) {
+ vocabularyClosure.clear();
+ validationCache.clear();
+ }
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java
index 80775c3b9..b01ac7dbf 100644
--- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java
+++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/Validator.java
@@ -21,11 +21,14 @@
import cz.cvut.kbss.jopa.model.EntityManager;
import cz.cvut.kbss.jopa.model.MultilingualString;
import cz.cvut.kbss.jsonld.JsonLd;
+import cz.cvut.kbss.termit.event.VocabularyValidationFinishedEvent;
import cz.cvut.kbss.termit.exception.TermItException;
import cz.cvut.kbss.termit.model.validation.ValidationResult;
import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper;
import cz.cvut.kbss.termit.util.Configuration;
import cz.cvut.kbss.termit.util.Utils;
+import cz.cvut.kbss.termit.util.throttle.Throttle;
+import cz.cvut.kbss.termit.util.throttle.ThrottledFuture;
import org.apache.jena.rdf.model.Literal;
import org.apache.jena.rdf.model.Model;
import org.apache.jena.rdf.model.ModelFactory;
@@ -39,8 +42,10 @@
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource;
import org.springframework.core.io.support.PathMatchingResourcePatternResolver;
+import org.springframework.lang.NonNull;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;
@@ -79,16 +84,17 @@ public class Validator implements VocabularyContentValidator {
private final EntityManager em;
private final VocabularyContextMapper vocabularyContextMapper;
+ private final ApplicationEventPublisher eventPublisher;
- private com.github.sgov.server.Validator validator;
private Model validationModel;
@Autowired
public Validator(EntityManager em,
VocabularyContextMapper vocabularyContextMapper,
- Configuration config) {
+ Configuration config, ApplicationEventPublisher eventPublisher) {
this.em = em;
this.vocabularyContextMapper = vocabularyContextMapper;
+ this.eventPublisher = eventPublisher;
initValidator(config.getPersistence().getLanguage());
}
@@ -102,8 +108,7 @@ public Validator(EntityManager em,
*/
private void initValidator(String language) {
try {
- this.validator = new com.github.sgov.server.Validator();
- this.validationModel = initValidationModel(validator, language);
+ this.validationModel = initValidationModel(new com.github.sgov.server.Validator(), language);
} catch (IOException e) {
throw new TermItException("Unable to initialize validator.", e);
}
@@ -138,27 +143,44 @@ private void loadOverrideRules(Model validationModel, String language) throws IO
}
}
+ @Throttle(value = "{#originVocabularyIri}", name = "vocabularyValidation")
@Transactional(readOnly = true)
@Override
- public List validate(final Collection vocabularyIris) {
+ @NonNull
+ public ThrottledFuture> validate(final @NonNull URI originVocabularyIri, final @NonNull Collection vocabularyIris) {
+ if (vocabularyIris.isEmpty()) {
+ return ThrottledFuture.done(List.of());
+ }
+
+ return ThrottledFuture.of(() -> {
+ final List results = runValidation(vocabularyIris);
+ eventPublisher.publishEvent(new VocabularyValidationFinishedEvent(this, originVocabularyIri, vocabularyIris, results));
+ return results;
+ });
+ }
+
+ protected synchronized List runValidation(@NonNull Collection vocabularyIris) {
LOG.debug("Validating {}", vocabularyIris);
try {
+ LOG.trace("Constructing model from RDF4J repository...");
final Model dataModel = getModelFromRdf4jRepository(vocabularyIris);
- org.topbraid.shacl.validation.ValidationReport report = validator.validate(dataModel, validationModel);
+ LOG.trace("Model constructed, running validation...");
+ org.topbraid.shacl.validation.ValidationReport report = new com.github.sgov.server.Validator()
+ .validate(dataModel, validationModel);
LOG.debug("Done.");
return report.results().stream()
.sorted(new ValidationResultSeverityComparator()).map(result -> {
final URI termUri = URI.create(result.getFocusNode().toString());
final URI severity = URI.create(result.getSeverity().getURI());
final URI errorUri = result.getSourceShape().isURIResource() ?
- URI.create(result.getSourceShape().getURI()) : null;
+ URI.create(result.getSourceShape().getURI()) : null;
final URI resultPath = result.getPath() != null && result.getPath().isURIResource() ?
- URI.create(result.getPath().getURI()) : null;
+ URI.create(result.getPath().getURI()) : null;
final MultilingualString messages = new MultilingualString(result.getMessages().stream()
.map(RDFNode::asLiteral)
.collect(Collectors.toMap(
lit -> lit.getLanguage().isBlank() ?
- JsonLd.NONE : lit.getLanguage(),
+ JsonLd.NONE : lit.getLanguage(),
Literal::getLexicalForm)));
return new ValidationResult()
diff --git a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java
index 85ce431f0..54ffa94ae 100644
--- a/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java
+++ b/src/main/java/cz/cvut/kbss/termit/persistence/validation/VocabularyContentValidator.java
@@ -18,10 +18,11 @@
package cz.cvut.kbss.termit.persistence.validation;
import cz.cvut.kbss.termit.model.validation.ValidationResult;
+import cz.cvut.kbss.termit.util.throttle.ThrottledFuture;
+import org.springframework.lang.NonNull;
import java.net.URI;
import java.util.Collection;
-import java.util.List;
/**
* Allows validating the content of vocabularies based on preconfigured rules.
@@ -33,8 +34,10 @@ public interface VocabularyContentValidator {
*
* The vocabularies are validated together, as a single unit.
*
- * @param vocabularyIris Vocabulary identifiers
+ * @param originVocabularyIri the origin vocabulary IRI
+ * @param vocabularyIris Vocabulary identifiers (including {@code originVocabularyIri}
* @return List of violations of validation rules. Empty list if there are not violations
*/
- List validate(final Collection vocabularyIris);
+ @NonNull
+ ThrottledFuture> validate(@NonNull URI originVocabularyIri, @NonNull Collection vocabularyIris);
}
diff --git a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java
index f389a328a..11bb65415 100644
--- a/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java
+++ b/src/main/java/cz/cvut/kbss/termit/rest/ResourceController.java
@@ -148,8 +148,8 @@ public ResponseEntity getContent(
try {
final Optional timestamp = at.map(RestUtils::parseTimestamp);
final TypeAwareResource content = resourceService.getContent(resource,
- new ResourceRetrievalSpecification(timestamp,
- withoutUnconfirmedOccurrences));
+ new ResourceRetrievalSpecification(timestamp,
+ withoutUnconfirmedOccurrences));
final ResponseEntity.BodyBuilder builder = ResponseEntity.ok()
.contentLength(content.contentLength())
.contentType(MediaType.parseMediaType(
@@ -172,23 +172,24 @@ public ResponseEntity getContent(
})
@PutMapping(value = "/{localName}/content")
@ResponseStatus(HttpStatus.NO_CONTENT)
- public void saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
- example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
- @PathVariable String localName,
- @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
- example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE)
- @RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace,
- @Parameter(description = "File with the new content.")
- @RequestParam(name = "file") MultipartFile attachment) {
+ public Void saveContent(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
+ example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
+ @PathVariable String localName,
+ @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
+ example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE)
+ @RequestParam(name = QueryParams.NAMESPACE,
+ required = false) Optional namespace,
+ @Parameter(description = "File with the new content.")
+ @RequestParam(name = "file") MultipartFile attachment) {
+
final Resource resource = getResource(localName, namespace);
try {
resourceService.saveContent(resource, attachment.getInputStream());
} catch (IOException e) {
- throw new TermItException(
- "Unable to read file (fileName=\"" + attachment.getOriginalFilename() + "\") content from request.",
- e);
+ throw new TermItException("Unable to read file (fileName=\"" + attachment.getOriginalFilename() + "\") content from request.", e);
}
LOG.debug("Content saved for resource {}.", resource);
+ return null;
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")},
@@ -212,8 +213,8 @@ public ResponseEntity hasContent(@Parameter(description = ResourceControll
return ResponseEntity.notFound().build();
} else {
final String contentType = resourceService.getContent(r,
- new ResourceRetrievalSpecification(Optional.empty(),
- false))
+ new ResourceRetrievalSpecification(Optional.empty(),
+ false))
.getMediaType().orElse(null);
return ResponseEntity.noContent().header(HttpHeaders.CONTENT_TYPE, contentType).build();
}
@@ -297,7 +298,7 @@ public void removeFileFromDocument(@Parameter(description = ResourceControllerDo
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")},
- description = "Runs text analysis on the content of the resource with the specified identifier.")
+ description = "Runs text analysis on the content of the resource with the specified identifier. Analysis will be performed asynchronously sometime in the future.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Text analysis executed."),
@ApiResponse(responseCode = "404", description = ResourceControllerDoc.ID_NOT_FOUND_DESCRIPTION),
@@ -306,19 +307,18 @@ public void removeFileFromDocument(@Parameter(description = ResourceControllerDo
@PutMapping(value = "/{localName}/text-analysis")
@ResponseStatus(HttpStatus.NO_CONTENT)
public void runTextAnalysis(@Parameter(description = ResourceControllerDoc.ID_LOCAL_NAME_DESCRIPTION,
- example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
- @PathVariable String localName,
- @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
- example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE)
- @RequestParam(name = QueryParams.NAMESPACE,
- required = false) Optional namespace,
- @Parameter(
- description = "Identifiers of vocabularies whose terms are used to seed text analysis.")
- @RequestParam(name = "vocabulary", required = false,
- defaultValue = "") Set vocabularies) {
+ example = ResourceControllerDoc.ID_LOCAL_NAME_EXAMPLE)
+ @PathVariable String localName,
+ @Parameter(description = ResourceControllerDoc.ID_NAMESPACE_DESCRIPTION,
+ example = ResourceControllerDoc.ID_NAMESPACE_EXAMPLE)
+ @RequestParam(name = QueryParams.NAMESPACE,
+ required = false) Optional namespace,
+ @Parameter(
+ description = "Identifiers of vocabularies whose terms are used to seed text analysis.")
+ @RequestParam(name = "vocabulary", required = false,
+ defaultValue = "") Set vocabularies) {
final Resource resource = getResource(localName, namespace);
resourceService.runTextAnalysis(resource, vocabularies);
- LOG.debug("Text analysis finished for resource {}.", resource);
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")},
@@ -367,10 +367,15 @@ public List getHistory(
* A couple of constants for the {@link ResourceController} API documentation.
*/
private static final class ResourceControllerDoc {
+
private static final String ID_LOCAL_NAME_DESCRIPTION = "Locally (in the context of the specified namespace/default resource namespace) unique part of the resource identifier.";
+
private static final String ID_LOCAL_NAME_EXAMPLE = "mpp-draft.html";
+
private static final String ID_NAMESPACE_DESCRIPTION = "Identifier namespace. Allows to override the default resource identifier namespace.";
+
private static final String ID_NAMESPACE_EXAMPLE = "http://onto.fel.cvut.cz/ontologies/zdroj/";
+
private static final String ID_NOT_FOUND_DESCRIPTION = "Resource with the specified identifier not found.";
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java
index beec58274..9fc059aa9 100644
--- a/src/main/java/cz/cvut/kbss/termit/rest/TermController.java
+++ b/src/main/java/cz/cvut/kbss/termit/rest/TermController.java
@@ -220,14 +220,16 @@ public ResponseEntity checkTerms(
@Parameter(description = "Language of the label.")
@RequestParam(name = "language", required = false) String language) {
final URI vocabularyUri = getVocabularyUri(namespace, localName);
- final Vocabulary vocabulary = termService.getVocabularyReference(vocabularyUri);
- if (prefLabel != null) {
- final boolean exists = termService.existsInVocabulary(prefLabel, vocabulary, language);
- return new ResponseEntity<>(exists ? HttpStatus.OK : HttpStatus.NOT_FOUND);
- } else {
- final Integer count = termService.getTermCount(vocabulary);
- return ResponseEntity.ok().header(Constants.X_TOTAL_COUNT_HEADER, count.toString()).build();
- }
+
+ final Vocabulary vocabulary = termService.getVocabularyReference(vocabularyUri);
+ if (prefLabel != null) {
+ final boolean exists = termService.existsInVocabulary(prefLabel, vocabulary, language);
+ return new ResponseEntity<>(exists ? HttpStatus.OK : HttpStatus.NOT_FOUND);
+ } else {
+ final Integer count = termService.getTermCount(vocabulary);
+ return ResponseEntity.ok().header(Constants.X_TOTAL_COUNT_HEADER, count.toString()).build();
+ }
+
}
private Vocabulary getVocabulary(URI vocabularyUri) {
@@ -270,11 +272,13 @@ public List getAllRoots(
@Parameter(
description = "Identifiers of terms that should be included in the response (regardless of whether they are root terms or not).")
@RequestParam(name = "includeTerms", required = false, defaultValue = "") List includeTerms) {
+
final Vocabulary vocabulary = getVocabulary(getVocabularyUri(namespace, localName));
return includeImported ?
- termService
- .findAllRootsIncludingImported(vocabulary, createPageRequest(pageSize, pageNo), includeTerms) :
- termService.findAllRoots(vocabulary, createPageRequest(pageSize, pageNo), includeTerms);
+ termService
+ .findAllRootsIncludingImported(vocabulary, createPageRequest(pageSize, pageNo), includeTerms) :
+ termService.findAllRoots(vocabulary, createPageRequest(pageSize, pageNo), includeTerms);
+
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")},
@@ -608,8 +612,8 @@ public void runTextAnalysisOnTerm(
@PathVariable String termLocalName,
@Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION, example = ApiDoc.ID_NAMESPACE_EXAMPLE)
@RequestParam(name = QueryParams.NAMESPACE, required = false) Optional namespace) {
- termService.analyzeTermDefinition(getById(localName, termLocalName, namespace),
- getVocabularyUri(namespace, localName));
+ LOG.warn("Called legacy endpoint intended for internal use or testing only! (/vocabularies/{}/terms/{}/text-analysis)", localName, termLocalName);
+ termService.analyzeTermDefinition(getById(localName, termLocalName, namespace), getVocabularyUri(namespace, localName));
}
@Operation(security = {@SecurityRequirement(name = "bearer-key")},
diff --git a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
index 881e6b71b..c03272516 100644
--- a/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
+++ b/src/main/java/cz/cvut/kbss/termit/rest/VocabularyController.java
@@ -381,11 +381,11 @@ public void removeVocabulary(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCR
@GetMapping(value = "/{localName}/relations")
public List relations(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION,
example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
- @PathVariable String localName,
+ @PathVariable String localName,
@Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION,
- example = ApiDoc.ID_NAMESPACE_EXAMPLE)
- @RequestParam(name = QueryParams.NAMESPACE,
- required = false) Optional namespace) {
+ example = ApiDoc.ID_NAMESPACE_EXAMPLE)
+ @RequestParam(name = QueryParams.NAMESPACE,
+ required = false) Optional namespace) {
final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName);
final Vocabulary vocabulary = vocabularyService.findRequired(identifier);
@@ -401,11 +401,11 @@ public List relations(@Parameter(description = ApiDoc.ID_LOCAL_NA
@GetMapping(value = "/{localName}/terms/relations")
public List termsRelations(@Parameter(description = ApiDoc.ID_LOCAL_NAME_DESCRIPTION,
example = ApiDoc.ID_LOCAL_NAME_EXAMPLE)
- @PathVariable String localName,
+ @PathVariable String localName,
@Parameter(description = ApiDoc.ID_NAMESPACE_DESCRIPTION,
- example = ApiDoc.ID_NAMESPACE_EXAMPLE)
- @RequestParam(name = QueryParams.NAMESPACE,
- required = false) Optional namespace) {
+ example = ApiDoc.ID_NAMESPACE_EXAMPLE)
+ @RequestParam(name = QueryParams.NAMESPACE,
+ required = false) Optional namespace) {
final URI identifier = resolveIdentifier(namespace.orElse(config.getNamespace().getVocabulary()), localName);
final Vocabulary vocabulary = vocabularyService.findRequired(identifier);
diff --git a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java
index 03d50a199..1a304d8bf 100644
--- a/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java
+++ b/src/main/java/cz/cvut/kbss/termit/rest/handler/RestExceptionHandler.java
@@ -44,13 +44,15 @@
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
-import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.context.request.async.AsyncRequestNotUsableException;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
+import static cz.cvut.kbss.termit.util.ExceptionUtils.isCausedBy;
+
/**
* Exception handlers for REST controllers.
*
@@ -80,7 +82,10 @@ private static void logException(Throwable ex, HttpServletRequest request) {
}
private static void logException(String message, Throwable ex) {
- LOG.error(message, ex);
+ // Prevents exceptions caused by broken connection with a client from logging
+ if (!isCausedBy(ex, AsyncRequestNotUsableException.class)) {
+ LOG.error(message, ex);
+ }
}
private static ErrorInfo errorInfo(HttpServletRequest request, Throwable e) {
@@ -132,21 +137,7 @@ public ResponseEntity authorizationException(HttpServletRequest reque
public ResponseEntity authenticationException(HttpServletRequest request, AuthenticationException e) {
LOG.warn("Authentication failure during HTTP request to {}: {}", request.getRequestURI(), e.getMessage());
LOG.atDebug().setCause(e).log(e.getMessage());
- return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN);
- }
-
- /**
- * Fired, for example, on method security violation
- */
- @ExceptionHandler(AccessDeniedException.class)
- public ResponseEntity accessDeniedException(HttpServletRequest request, AccessDeniedException e) {
- LOG.atWarn().setMessage("[{}] Unauthorized access: {}").addArgument(() -> {
- if (request.getUserPrincipal() != null) {
- return request.getUserPrincipal().getName();
- }
- return "(unknown user)";
- }).addArgument(e.getMessage()).log();
- return new ResponseEntity<>(errorInfo(request, e), HttpStatus.FORBIDDEN);
+ return new ResponseEntity<>(errorInfo(request, e), HttpStatus.UNAUTHORIZED);
}
@ExceptionHandler(ValidationException.class)
diff --git a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java
index 3d8b52c4c..e10d16462 100644
--- a/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java
+++ b/src/main/java/cz/cvut/kbss/termit/security/JwtUtils.java
@@ -64,15 +64,16 @@ public class JwtUtils {
private final ObjectMapper objectMapper;
- private final Key key;
-
private final JwtParser jwtParser;
+ private final Key key;
+
@Autowired
public JwtUtils(@Qualifier("objectMapper") ObjectMapper objectMapper, Configuration config) {
this.objectMapper = objectMapper;
this.key = Utils.isBlank(config.getJwt().getSecretKey()) ? Keys.secretKeyFor(SIGNATURE_ALGORITHM) :
Keys.hmacShaKeyFor(config.getJwt().getSecretKey().getBytes(StandardCharsets.UTF_8));
+
this.jwtParser = Jwts.parserBuilder().setSigningKey(key)
.deserializeJsonWith(new JacksonDeserializer<>(objectMapper))
.build();
diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java
index faa633d75..f3d7a9cc7 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/business/ResourceService.java
@@ -98,7 +98,7 @@ public ResourceService(ResourceRepositoryService repositoryService, DocumentMana
*/
@EventListener
public void onVocabularyRemoval(VocabularyWillBeRemovedEvent event) {
- vocabularyService.find(event.getVocabulary()).ifPresent(vocabulary -> {
+ vocabularyService.find(event.getVocabularyIri()).ifPresent(vocabulary -> {
if(vocabulary.getDocument() != null) {
remove(vocabulary.getDocument());
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java
index 983f00eb1..db3bb6564 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/business/TermService.java
@@ -42,6 +42,7 @@
import cz.cvut.kbss.termit.util.Configuration;
import cz.cvut.kbss.termit.util.TypeAwareResource;
import cz.cvut.kbss.termit.util.Utils;
+import cz.cvut.kbss.termit.util.throttle.Throttle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -50,6 +51,7 @@
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.net.URI;
@@ -374,10 +376,7 @@ public void persistRoot(Term term, Vocabulary owner) {
Objects.requireNonNull(owner);
languageService.getInitialTermState().ifPresent(is -> term.setState(is.getUri()));
repositoryService.addRootTermToVocabulary(term, owner);
- if (!config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) {
- analyzeTermDefinition(term, owner.getUri());
- vocabularyService.runTextAnalysisOnAllTerms(owner);
- }
+ vocabularyService.runTextAnalysisOnAllTerms(owner);
}
/**
@@ -392,10 +391,7 @@ public void persistChild(Term child, Term parent) {
Objects.requireNonNull(parent);
languageService.getInitialTermState().ifPresent(is -> child.setState(is.getUri()));
repositoryService.addChildTerm(child, parent);
- if (!config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) {
- analyzeTermDefinition(child, parent.getVocabulary());
- vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary()));
- }
+ vocabularyService.runTextAnalysisOnAllTerms(findVocabularyRequired(parent.getVocabulary()));
}
/**
@@ -412,11 +408,14 @@ public Term update(Term term) {
checkForInvalidTerminalStateAssignment(original, term.getState());
// Ensure the change is merged into the repo before analyzing other terms
final Term result = repositoryService.update(term);
- if (!Objects.equals(original.getDefinition(), term.getDefinition()) && !config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) {
- analyzeTermDefinition(term, original.getVocabulary());
- }
- if (!Objects.equals(original.getLabel(), term.getLabel()) && !config.getTextAnalysis().isDisableVocabularyAnalysisOnTermEdit()) {
- vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(original.getVocabulary()));
+
+ // if the label changed, run analysis on all terms in the vocabulary
+ if (!Objects.equals(original.getLabel(), result.getLabel())) {
+ vocabularyService.runTextAnalysisOnAllTerms(getVocabularyReference(result.getVocabulary()));
+ // if all terms have not been analyzed, check if the definition has changed,
+ // and if so, perform an analysis for the term definition
+ } else if (!Objects.equals(original.getDefinition(), result.getDefinition())) {
+ analyzeTermDefinition(result, result.getVocabulary());
}
return result;
}
@@ -441,8 +440,13 @@ public void remove(@NonNull Term term) {
* @param term Term to analyze
* @param vocabularyIri Identifier of the vocabulary used for analysis
*/
+ @Throttle(value = "{#vocabularyIri, #term.getUri()}",
+ group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyTerm(#vocabulary.getUri(), #term.getUri())",
+ name="termDefinitionAnalysis")
+ @Transactional(propagation = Propagation.REQUIRES_NEW)
@PreAuthorize("@termAuthorizationService.canModify(#term)")
public void analyzeTermDefinition(AbstractTerm term, URI vocabularyIri) {
+ term = findRequired(term.getUri()); // required when throttling for persistent context
Objects.requireNonNull(term);
if (term.getDefinition() == null || term.getDefinition().isEmpty()) {
return;
diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java
index 69a2dfc22..1d20cf5b2 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/business/VocabularyService.java
@@ -24,7 +24,9 @@
import cz.cvut.kbss.termit.dto.acl.AccessControlListDto;
import cz.cvut.kbss.termit.dto.listing.TermDto;
import cz.cvut.kbss.termit.dto.listing.VocabularyDto;
+import cz.cvut.kbss.termit.event.VocabularyContentModifiedEvent;
import cz.cvut.kbss.termit.event.VocabularyCreatedEvent;
+import cz.cvut.kbss.termit.event.VocabularyEvent;
import cz.cvut.kbss.termit.exception.NotFoundException;
import cz.cvut.kbss.termit.model.Vocabulary;
import cz.cvut.kbss.termit.model.acl.AccessControlList;
@@ -34,7 +36,6 @@
import cz.cvut.kbss.termit.model.validation.ValidationResult;
import cz.cvut.kbss.termit.persistence.context.VocabularyContextMapper;
import cz.cvut.kbss.termit.persistence.snapshot.SnapshotCreator;
-import cz.cvut.kbss.termit.service.business.async.AsyncTermService;
import cz.cvut.kbss.termit.service.changetracking.ChangeRecordProvider;
import cz.cvut.kbss.termit.service.export.ExportFormat;
import cz.cvut.kbss.termit.service.repository.ChangeRecordService;
@@ -45,6 +46,8 @@
import cz.cvut.kbss.termit.util.TypeAwareClasspathResource;
import cz.cvut.kbss.termit.util.TypeAwareFileSystemResource;
import cz.cvut.kbss.termit.util.TypeAwareResource;
+import cz.cvut.kbss.termit.util.throttle.CacheableFuture;
+import cz.cvut.kbss.termit.util.throttle.Throttle;
import org.jetbrains.annotations.NotNull;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
@@ -52,6 +55,7 @@
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.context.ApplicationEventPublisherAware;
import org.springframework.context.annotation.Lazy;
+import org.springframework.context.event.EventListener;
import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.security.access.prepost.PostFilter;
import org.springframework.security.access.prepost.PreAuthorize;
@@ -90,7 +94,7 @@ public class VocabularyService
private final ChangeRecordService changeRecordService;
- private final AsyncTermService termService;
+ private final TermService termService;
private final VocabularyContextMapper contextMapper;
@@ -104,7 +108,7 @@ public class VocabularyService
public VocabularyService(VocabularyRepositoryService repositoryService,
ChangeRecordService changeRecordService,
- @Lazy AsyncTermService termService,
+ @Lazy TermService termService,
VocabularyContextMapper contextMapper,
AccessControlListService aclService,
VocabularyAuthorizationService authorizationService,
@@ -118,6 +122,16 @@ public VocabularyService(VocabularyRepositoryService repositoryService,
this.context = context;
}
+ /**
+ * Receives {@link VocabularyContentModifiedEvent} and triggers validation.
+ * The goal for this is to get the results cached and do not force users to wait for validation
+ * when they request it.
+ */
+ @EventListener({VocabularyContentModifiedEvent.class, VocabularyCreatedEvent.class})
+ public void onVocabularyContentModified(VocabularyEvent event) {
+ repositoryService.validateContents(event.getVocabularyIri());
+ }
+
@Override
@PostFilter("@vocabularyAuthorizationService.canRead(filterObject)")
public List findAll() {
@@ -168,7 +182,7 @@ public void persist(Vocabulary instance) {
repositoryService.persist(instance);
final AccessControlList acl = aclService.createFor(instance);
instance.setAcl(acl.getUri());
- eventPublisher.publishEvent(new VocabularyCreatedEvent(instance));
+ eventPublisher.publishEvent(new VocabularyCreatedEvent(this, instance.getUri()));
}
@Override
@@ -231,7 +245,7 @@ public Vocabulary importVocabulary(boolean rename, MultipartFile file) {
final Vocabulary imported = repositoryService.importVocabulary(rename, file);
final AccessControlList acl = aclService.createFor(imported);
imported.setAcl(acl.getUri());
- eventPublisher.publishEvent(new VocabularyCreatedEvent(imported));
+ eventPublisher.publishEvent(new VocabularyCreatedEvent(this, imported.getUri()));
return imported;
}
@@ -290,8 +304,12 @@ public List getChangesOfContent(Vocabulary vocabulary) {
* @param vocabulary Vocabulary to be analyzed
*/
@Transactional
+ @Throttle(value = "{#vocabulary.getUri()}",
+ group = "T(ThrottleGroupProvider).getTextAnalysisVocabularyAllTerms(#vocabulary.getUri())",
+ name = "allTermsVocabularyAnalysis")
@PreAuthorize("@vocabularyAuthorizationService.canModify(#vocabulary)")
public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) {
+ vocabulary = findRequired(vocabulary.getUri()); // required when throttling for persistent context
LOG.debug("Analyzing definitions of all terms in vocabulary {} and vocabularies it imports.", vocabulary);
SnapshotProvider.verifySnapshotNotModified(vocabulary);
final List allTerms = termService.findAll(vocabulary);
@@ -299,12 +317,13 @@ public void runTextAnalysisOnAllTerms(Vocabulary vocabulary) {
importedVocabulary -> allTerms.addAll(termService.findAll(getReference(importedVocabulary))));
final Map termsToContexts = new HashMap<>(allTerms.size());
allTerms.forEach(t -> termsToContexts.put(t, contextMapper.getVocabularyContext(t.getVocabulary())));
- termService.asyncAnalyzeTermDefinitions(termsToContexts);
+ termsToContexts.forEach(termService::analyzeTermDefinition);
}
/**
* Runs text analysis on definitions of all terms in all vocabularies.
*/
+ @Throttle(group = "T(ThrottleGroupProvider).getTextAnalysisVocabulariesAll()", name = "allVocabulariesAnalysis")
@Transactional
public void runTextAnalysisOnAllVocabularies() {
LOG.debug("Analyzing definitions of all terms in all vocabularies.");
@@ -312,7 +331,7 @@ public void runTextAnalysisOnAllVocabularies() {
repositoryService.findAll().forEach(v -> {
List terms = termService.findAll(new Vocabulary(v.getUri()));
terms.forEach(t -> termsToContexts.put(t, contextMapper.getVocabularyContext(t.getVocabulary())));
- termService.asyncAnalyzeTermDefinitions(termsToContexts);
+ termsToContexts.forEach(termService::analyzeTermDefinition);
});
}
@@ -337,10 +356,10 @@ public void remove(Vocabulary asset) {
/**
* Validates a vocabulary: - it checks glossary rules, - it checks OntoUml constraints.
*
- * @param validate Vocabulary to validate
+ * @param vocabulary Vocabulary to validate
*/
- public List validateContents(Vocabulary validate) {
- return repositoryService.validateContents(validate);
+ public CacheableFuture> validateContents(URI vocabulary) {
+ return repositoryService.validateContents(vocabulary);
}
/**
@@ -367,7 +386,7 @@ public Integer getTermCount(Vocabulary vocabulary) {
@PreAuthorize("@vocabularyAuthorizationService.canCreateSnapshot(#vocabulary)")
public Snapshot createSnapshot(Vocabulary vocabulary) {
final Snapshot s = getSnapshotCreator().createSnapshot(vocabulary);
- eventPublisher.publishEvent(new VocabularyCreatedEvent(s));
+ eventPublisher.publishEvent(new VocabularyCreatedEvent(this, s.getUri()));
cloneAccessControlList(s, vocabulary);
return s;
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java b/src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java
deleted file mode 100644
index fc807b733..000000000
--- a/src/main/java/cz/cvut/kbss/termit/service/business/async/AsyncTermService.java
+++ /dev/null
@@ -1,69 +0,0 @@
-/*
- * TermIt
- * Copyright (C) 2023 Czech Technical University in Prague
- *
- * This program is free software: you can redistribute it and/or modify
- * it under the terms of the GNU General Public License as published by
- * the Free Software Foundation, either version 3 of the License, or
- * (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
- * GNU General Public License for more details.
- *
- * You should have received a copy of the GNU General Public License
- * along with this program. If not, see .
- */
-package cz.cvut.kbss.termit.service.business.async;
-
-import cz.cvut.kbss.termit.dto.listing.TermDto;
-import cz.cvut.kbss.termit.model.AbstractTerm;
-import cz.cvut.kbss.termit.model.Vocabulary;
-import cz.cvut.kbss.termit.service.business.TermService;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Service;
-
-import java.net.URI;
-import java.util.List;
-import java.util.Map;
-
-/**
- * Provides asynchronous processing of term-related tasks.
- */
-@Service
-public class AsyncTermService {
-
- private static final Logger LOG = LoggerFactory.getLogger(AsyncTermService.class);
-
- private final TermService termService;
-
- public AsyncTermService(TermService termService) {
- this.termService = termService;
- }
-
- /**
- * Gets a list of all terms in the specified vocabulary.
- *
- * @param vocabulary Vocabulary whose terms to retrieve. A reference is sufficient
- * @return List of vocabulary term DTOs
- */
- public List findAll(Vocabulary vocabulary) {
- return termService.findAll(vocabulary);
- }
-
- /**
- * Asynchronously runs text analysis on the definitions of all the specified terms.
- *
- * The analysis calls are executed in a sequence, but this method itself is executed asynchronously.
- *
- * @param termsWithContexts Map of terms to vocabulary context identifiers they belong to
- */
- @Async
- public void asyncAnalyzeTermDefinitions(Map extends AbstractTerm, URI> termsWithContexts) {
- LOG.trace("Asynchronously analyzing definitions of {} terms.", termsWithContexts.size());
- termsWithContexts.forEach(termService::analyzeTermDefinition);
- }
-}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java
index 4333be04a..494263979 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/AnnotationGenerator.java
@@ -18,9 +18,12 @@
package cz.cvut.kbss.termit.service.document;
import cz.cvut.kbss.termit.exception.AnnotationGenerationException;
+import cz.cvut.kbss.termit.exception.TermItException;
import cz.cvut.kbss.termit.model.AbstractTerm;
+import cz.cvut.kbss.termit.model.Asset;
import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
import cz.cvut.kbss.termit.model.resource.File;
+import cz.cvut.kbss.termit.util.throttle.Throttle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
@@ -28,7 +31,10 @@
import org.springframework.transaction.annotation.Transactional;
import java.io.InputStream;
-import java.util.List;
+import java.util.concurrent.ArrayBlockingQueue;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.FutureTask;
+import java.util.concurrent.atomic.AtomicBoolean;
/**
* Creates annotations (term occurrences) for vocabulary terms.
@@ -39,6 +45,8 @@
@Service
public class AnnotationGenerator {
+ private static final long THREAD_JOIN_TIMEOUT = 1000L * 60; // 1 minute
+
private static final Logger LOG = LoggerFactory.getLogger(AnnotationGenerator.class);
private final DocumentManager documentManager;
@@ -48,8 +56,7 @@ public class AnnotationGenerator {
private final TermOccurrenceSaver occurrenceSaver;
@Autowired
- public AnnotationGenerator(DocumentManager documentManager,
- TermOccurrenceResolvers resolvers,
+ public AnnotationGenerator(DocumentManager documentManager, TermOccurrenceResolvers resolvers,
TermOccurrenceSaver occurrenceSaver) {
this.documentManager = documentManager;
this.resolvers = resolvers;
@@ -63,17 +70,63 @@ public AnnotationGenerator(DocumentManager documentManager,
* @param source Source file of the annotated document
*/
@Transactional
+ @Throttle(value = "{source.getUri()}", name = "documentAnnotationGeneration")
public void generateAnnotations(InputStream content, File source) {
final TermOccurrenceResolver occurrenceResolver = findResolverFor(source);
LOG.debug("Resolving annotations of file {}.", source);
occurrenceResolver.parseContent(content, source);
occurrenceResolver.setExistingOccurrences(occurrenceSaver.getExistingOccurrences(source));
- final List occurrences = occurrenceResolver.findTermOccurrences();
- saveAnnotatedContent(source, occurrenceResolver.getContent());
- occurrenceSaver.saveOccurrences(occurrences, source);
+ findAndSaveTermOccurrences(source, occurrenceResolver);
LOG.trace("Finished generating annotations for file {}.", source);
}
+ /**
+ * Calls {@link TermOccurrenceResolver#findTermOccurrences(TermOccurrenceResolver.OccurrenceConsumer)} on {@code #occurrenceResolver}
+ * creating new thread that will save any found occurrence in parallel.
+ * Saves annotated content ({@link #saveAnnotatedContent(File, InputStream)} when the source is a {@link File}.
+ */
+ private void findAndSaveTermOccurrences(Asset> source, TermOccurrenceResolver occurrenceResolver) {
+ AtomicBoolean finished = new AtomicBoolean(false);
+ // alternatively, SynchronousQueue could be used, but this allows to have some space as buffer
+ final ArrayBlockingQueue toSave = new ArrayBlockingQueue<>(10);
+ // not limiting the queue size would result in OutOfMemoryError
+
+ FutureTask findTask = new FutureTask<>(() -> {
+ try {
+ LOG.trace("Resolving term occurrences for {}.", source);
+ occurrenceResolver.findTermOccurrences(toSave::put);
+ LOG.trace("Finished resolving term occurrences for {}.", source);
+ LOG.trace("Saving term occurrences for {}.", source);
+ if (source instanceof File sourceFile) {
+ saveAnnotatedContent(sourceFile, occurrenceResolver.getContent());
+ }
+ LOG.trace("Term occurrences saved for {}.", source);
+ } finally {
+ finished.set(true);
+ }
+ return null;
+ });
+ Thread finder = new Thread(findTask);
+ finder.setName("AnnotationGenerator-TermOccurrenceResolver");
+ finder.start();
+
+ occurrenceSaver.saveFromQueue(source, finished, toSave);
+
+ try {
+ findTask.get(); // propagates exceptions
+ finder.join(THREAD_JOIN_TIMEOUT);
+ } catch (InterruptedException e) {
+ LOG.error("Thread interrupted while saving annotations of file {}.", source);
+ Thread.currentThread().interrupt();
+ throw new TermItException(e);
+ } catch (ExecutionException e) {
+ if (e.getCause() instanceof RuntimeException re) {
+ throw re;
+ }
+ throw new TermItException(e);
+ }
+ }
+
private TermOccurrenceResolver findResolverFor(File file) {
// This will allow us to potentially support different types of files
final TermOccurrenceResolver htmlResolver = resolvers.htmlTermOccurrenceResolver();
@@ -100,8 +153,7 @@ public void generateAnnotations(InputStream content, AbstractTerm annotatedTerm)
final TermOccurrenceResolver occurrenceResolver = resolvers.htmlTermOccurrenceResolver();
LOG.debug("Resolving annotations of the definition of {}.", annotatedTerm);
occurrenceResolver.parseContent(content, annotatedTerm);
- final List occurrences = occurrenceResolver.findTermOccurrences();
- occurrenceSaver.saveOccurrences(occurrences, annotatedTerm);
+ occurrenceResolver.findTermOccurrences(o -> occurrenceSaver.saveOccurrence(o, annotatedTerm));
LOG.trace("Finished generating annotations for the definition of {}.", annotatedTerm);
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java
deleted file mode 100644
index a12186af0..000000000
--- a/src/main/java/cz/cvut/kbss/termit/service/document/AsynchronousTermOccurrenceSaver.java
+++ /dev/null
@@ -1,42 +0,0 @@
-package cz.cvut.kbss.termit.service.document;
-
-import cz.cvut.kbss.termit.model.Asset;
-import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.context.annotation.Primary;
-import org.springframework.context.annotation.Profile;
-import org.springframework.scheduling.annotation.Async;
-import org.springframework.stereotype.Service;
-
-import java.util.List;
-
-/**
- * Saves term occurrences asynchronously.
- */
-@Primary
-@Service
-@Profile("!test")
-public class AsynchronousTermOccurrenceSaver implements TermOccurrenceSaver {
-
- private static final Logger LOG = LoggerFactory.getLogger(AsynchronousTermOccurrenceSaver.class);
-
- private final SynchronousTermOccurrenceSaver synchronousSaver;
-
- public AsynchronousTermOccurrenceSaver(SynchronousTermOccurrenceSaver synchronousSaver) {
- this.synchronousSaver = synchronousSaver;
- }
-
- @Async
- @Override
- public void saveOccurrences(List occurrences, Asset> source) {
- LOG.debug("Asynchronously saving term occurrences for asset {}.", source);
- synchronousSaver.saveOccurrences(occurrences, source);
- LOG.trace("Finished saving term occurrences for asset {}.", source);
- }
-
- @Override
- public List getExistingOccurrences(Asset> source) {
- return synchronousSaver.getExistingOccurrences(source);
- }
-}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
deleted file mode 100644
index e8ec00613..000000000
--- a/src/main/java/cz/cvut/kbss/termit/service/document/SynchronousTermOccurrenceSaver.java
+++ /dev/null
@@ -1,43 +0,0 @@
-package cz.cvut.kbss.termit.service.document;
-
-import cz.cvut.kbss.termit.model.Asset;
-import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
-import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-
-/**
- * Saves occurrences synchronously.
- *
- * Existing occurrences are reused if they match.
- */
-@Service
-public class SynchronousTermOccurrenceSaver implements TermOccurrenceSaver {
-
- private static final Logger LOG = LoggerFactory.getLogger(SynchronousTermOccurrenceSaver.class);
-
- private final TermOccurrenceDao termOccurrenceDao;
-
- public SynchronousTermOccurrenceSaver(TermOccurrenceDao termOccurrenceDao) {
- this.termOccurrenceDao = termOccurrenceDao;
- }
-
- @Transactional
- @Override
- public void saveOccurrences(List occurrences, Asset> source) {
- LOG.debug("Saving term occurrences for asset {}.", source);
- LOG.trace("Removing all existing occurrences in asset {}.", source);
- termOccurrenceDao.removeAll(source);
- LOG.trace("Persisting new occurrences in {}.", source);
- occurrences.stream().filter(o -> !o.getTerm().equals(source.getUri())).forEach(termOccurrenceDao::persist);
- }
-
- @Override
- public List getExistingOccurrences(Asset> source) {
- return termOccurrenceDao.findAllTargeting(source);
- }
-}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java
index 55964f5b3..616c0707d 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceResolver.java
@@ -31,6 +31,7 @@
import java.net.URI;
import java.util.Collections;
import java.util.List;
+import java.util.function.Consumer;
/**
* Base class for resolving term occurrences in an annotated document.
@@ -49,7 +50,7 @@ protected TermOccurrenceResolver(TermRepositoryService termService) {
* Parses the specified input into some abstract representation from which new terms and term occurrences can be
* extracted.
*
- * Note that this method has to be called before calling {@link #findTermOccurrences()}.
+ * Note that this method has to be called before calling {@link #findTermOccurrences(Consumer)}.
*
* @param input The input to parse
* @param source Original source of the input. Used for term occurrence generation
@@ -80,10 +81,10 @@ public void setExistingOccurrences(List existingOccurrences) {
*
* {@link #parseContent(InputStream, Asset)} has to be called prior to this method.
*
- * @return List of term occurrences identified in the input
+ * @param resultConsumer the consumer that will be called for each result
* @see #parseContent(InputStream, Asset)
*/
- public abstract List findTermOccurrences();
+ public abstract void findTermOccurrences(OccurrenceConsumer resultConsumer);
/**
* Checks whether this resolver supports the specified source file type.
@@ -102,11 +103,11 @@ public void setExistingOccurrences(List existingOccurrences) {
*/
protected TermOccurrence createOccurrence(URI termUri, Asset> source) {
final TermOccurrence occurrence;
- if (source instanceof File) {
- final FileOccurrenceTarget target = new FileOccurrenceTarget((File) source);
+ if (source instanceof File file) {
+ final FileOccurrenceTarget target = new FileOccurrenceTarget(file);
occurrence = new TermFileOccurrence(termUri, target);
- } else if (source instanceof AbstractTerm) {
- final DefinitionalOccurrenceTarget target = new DefinitionalOccurrenceTarget((AbstractTerm) source);
+ } else if (source instanceof AbstractTerm abstractTerm) {
+ final DefinitionalOccurrenceTarget target = new DefinitionalOccurrenceTarget(abstractTerm);
occurrence = new TermDefinitionalOccurrence(termUri, target);
} else {
throw new IllegalArgumentException("Unsupported term occurrence source " + source);
@@ -114,4 +115,9 @@ protected TermOccurrence createOccurrence(URI termUri, Asset> source) {
occurrence.markSuggested();
return occurrence;
}
+
+ @FunctionalInterface
+ public interface OccurrenceConsumer {
+ void accept(TermOccurrence termOccurrence) throws InterruptedException;
+ }
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java
index 85286d4bb..9843a9864 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/TermOccurrenceSaver.java
@@ -1,24 +1,99 @@
package cz.cvut.kbss.termit.service.document;
+import cz.cvut.kbss.termit.exception.TermItException;
import cz.cvut.kbss.termit.model.Asset;
import cz.cvut.kbss.termit.model.assignment.TermOccurrence;
+import cz.cvut.kbss.termit.persistence.dao.TermOccurrenceDao;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
import java.util.List;
+import java.util.concurrent.BlockingQueue;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
/**
- * Saves occurrences of terms.
+ * Saves occurrences synchronously.
+ *
+ * Existing occurrences are reused if they match.
*/
-public interface TermOccurrenceSaver {
+@Service
+public class TermOccurrenceSaver {
+
+ private static final Logger LOG = LoggerFactory.getLogger(TermOccurrenceSaver.class);
+
+ private final TermOccurrenceDao termOccurrenceDao;
+
+ public TermOccurrenceSaver(TermOccurrenceDao termOccurrenceDao) {
+ this.termOccurrenceDao = termOccurrenceDao;
+ }
/**
* Saves the specified occurrences of terms in the specified asset.
*
+ * Removes all existing occurrences.
+ *
* Implementations may reuse existing occurrences if they match the provided ones.
*
* @param occurrences Occurrences to save
* @param source Asset in which the terms occur
*/
- void saveOccurrences(List occurrences, Asset> source);
+ @Transactional
+ public void saveOccurrences(List occurrences, Asset> source) {
+ LOG.debug("Saving term occurrences for asset {}.", source);
+ removeAll(source);
+ LOG.trace("Persisting new occurrences in {}.", source);
+ occurrences.stream().filter(o -> !o.getTerm().equals(source.getUri())).forEach(termOccurrenceDao::persist);
+ }
+
+ public void saveOccurrence(TermOccurrence occurrence, Asset> source) {
+ if (occurrence.getTerm().equals(source.getUri())) {
+ return;
+ }
+ if(!termOccurrenceDao.exists(occurrence.getUri())) {
+ termOccurrenceDao.persist(occurrence);
+ } else {
+ LOG.debug("Occurrence already exists, skipping: {}", occurrence);
+ }
+ }
+
+ /**
+ * Continously saves occurrences from the queue while blocking current thread until
+ * {@code #finished} is set to {@code true}.
+ *
+ * Removes all existing occurrences before processing.
+ *
+ * @param source Asset in which the terms occur
+ * @param finished Whether all occurrences were added to the queue
+ * @param toSave the queue with occurrences to save
+ */
+ @Transactional
+ public void saveFromQueue(final Asset> source, final AtomicBoolean finished,
+ final BlockingQueue toSave) {
+ LOG.debug("Saving term occurrences for asset {}.", source);
+ removeAll(source);
+ TermOccurrence occurrence;
+ long count = 0;
+ try {
+ while (!finished.get() || !toSave.isEmpty()) {
+ if (toSave.isEmpty()) {
+ Thread.yield();
+ }
+ occurrence = toSave.poll(1, TimeUnit.SECONDS);
+ if (occurrence != null) {
+ saveOccurrence(occurrence, source);
+ count++;
+ }
+ }
+ LOG.debug("Saved {} term occurrences for assert {}.", count, source);
+ } catch (InterruptedException e) {
+ LOG.error("Thread interrupted while waiting for occurrences to save.");
+ Thread.currentThread().interrupt();
+ throw new TermItException(e);
+ }
+ }
/**
* Gets a list of existing term occurrences in the specified asset.
@@ -26,5 +101,12 @@ public interface TermOccurrenceSaver {
* @param source Asset in which the terms occur
* @return List of existing term occurrences
*/
- List getExistingOccurrences(Asset> source);
+ public List getExistingOccurrences(Asset> source) {
+ return termOccurrenceDao.findAllTargeting(source);
+ }
+
+ private void removeAll(Asset> source) {
+ LOG.trace("Removing all existing occurrences in asset {}.", source);
+ termOccurrenceDao.removeAll(source);
+ }
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java
index dbc94dfaf..adc9dfdae 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/TextAnalysisService.java
@@ -18,6 +18,8 @@
package cz.cvut.kbss.termit.service.document;
import cz.cvut.kbss.termit.dto.TextAnalysisInput;
+import cz.cvut.kbss.termit.event.FileTextAnalysisFinishedEvent;
+import cz.cvut.kbss.termit.event.TermDefinitionTextAnalysisFinishedEvent;
import cz.cvut.kbss.termit.exception.WebServiceIntegrationException;
import cz.cvut.kbss.termit.model.AbstractTerm;
import cz.cvut.kbss.termit.model.TextAnalysisRecord;
@@ -25,9 +27,11 @@
import cz.cvut.kbss.termit.persistence.dao.TextAnalysisRecordDao;
import cz.cvut.kbss.termit.util.Configuration;
import cz.cvut.kbss.termit.util.Utils;
+import cz.cvut.kbss.termit.util.throttle.Throttle;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
import org.springframework.core.io.Resource;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
@@ -61,14 +65,18 @@ public class TextAnalysisService {
private final TextAnalysisRecordDao recordDao;
+ private final ApplicationEventPublisher eventPublisher;
+
@Autowired
public TextAnalysisService(RestTemplate restClient, Configuration config, DocumentManager documentManager,
- AnnotationGenerator annotationGenerator, TextAnalysisRecordDao recordDao) {
+ AnnotationGenerator annotationGenerator, TextAnalysisRecordDao recordDao,
+ ApplicationEventPublisher eventPublisher) {
this.restClient = restClient;
this.config = config;
this.documentManager = documentManager;
this.annotationGenerator = annotationGenerator;
this.recordDao = recordDao;
+ this.eventPublisher = eventPublisher;
}
/**
@@ -80,12 +88,15 @@ public TextAnalysisService(RestTemplate restClient, Configuration config, Docume
* @param file File whose content shall be analyzed
* @param vocabularyContexts Identifiers of repository contexts containing vocabularies intended for text analysis
*/
+ @Throttle(value = "{#file.getUri()}", name = "fileAnalysis")
@Transactional
public void analyzeFile(File file, Set vocabularyContexts) {
Objects.requireNonNull(file);
final TextAnalysisInput input = createAnalysisInput(file);
input.setVocabularyContexts(vocabularyContexts);
invokeTextAnalysisOnFile(file, input);
+ LOG.debug("Text analysis finished for resource {}.", file.getUri());
+ eventPublisher.publishEvent(new FileTextAnalysisFinishedEvent(this, file));
}
private TextAnalysisInput createAnalysisInput(File file) {
@@ -179,6 +190,7 @@ public void analyzeTermDefinition(AbstractTerm term, URI vocabularyContext) {
input.setVocabularyRepositoryPassword(config.getRepository().getPassword());
invokeTextAnalysisOnTerm(term, input);
+ eventPublisher.publishEvent(new TermDefinitionTextAnalysisFinishedEvent(this, term));
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java
index c67c466ca..2983c3c51 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/HtmlTermOccurrenceResolver.java
@@ -18,6 +18,7 @@
package cz.cvut.kbss.termit.service.document.html;
import cz.cvut.kbss.termit.exception.AnnotationGenerationException;
+import cz.cvut.kbss.termit.exception.TermItException;
import cz.cvut.kbss.termit.model.Asset;
import cz.cvut.kbss.termit.model.Term;
import cz.cvut.kbss.termit.model.assignment.OccurrenceTarget;
@@ -47,10 +48,8 @@
import java.io.InputStream;
import java.net.URI;
import java.nio.charset.StandardCharsets;
-import java.util.ArrayList;
import java.util.HashMap;
import java.util.HashSet;
-import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
@@ -65,15 +64,19 @@
public class HtmlTermOccurrenceResolver extends TermOccurrenceResolver {
private static final String BNODE_PREFIX = "_:";
+
private static final String SCORE_ATTRIBUTE = "score";
private static final Logger LOG = LoggerFactory.getLogger(HtmlTermOccurrenceResolver.class);
private final HtmlSelectorGenerators selectorGenerators;
+
private final DocumentManager documentManager;
+
private final Configuration config;
private Document document;
+
private Asset> source;
private Map prefixes;
@@ -152,11 +155,10 @@ private String fullIri(String possiblyPrefixed) {
}
@Override
- public List findTermOccurrences() {
+ public void findTermOccurrences(OccurrenceConsumer resultConsumer) {
assert document != null;
final Set visited = new HashSet<>();
final Elements elements = document.getElementsByAttribute(Constants.RDFa.ABOUT);
- final List result = new ArrayList<>(elements.size());
final Double scoreThreshold = Double.parseDouble(config.getTextAnalysis().getTermOccurrenceMinScore());
for (Element element : elements) {
if (isNotTermOccurrence(element)) {
@@ -171,27 +173,31 @@ public List findTermOccurrences() {
LOG.trace("Processing RDFa annotated element {}.", element);
final Optional occurrence = resolveAnnotation(element, source);
occurrence.ifPresent(to -> {
- if (!to.isSuggested()) {
- // Occurrence already approved in content (from previous manual approval)
- result.add(to);
- } else if (existsApproved(to)) {
- LOG.trace("Found term occurrence {} with matching existing approved occurrence.", to);
- to.markApproved();
- // Annotation without score is considered approved by the frontend
- element.removeAttr(SCORE_ATTRIBUTE);
- result.add(to);
- } else {
- if (to.getScore() > scoreThreshold) {
- LOG.trace("Found term occurrence {}.", to);
- result.add(to);
+ try {
+ if (!to.isSuggested()) {
+ // Occurrence already approved in content (from previous manual approval)
+ resultConsumer.accept(to);
+ } else if (existsApproved(to)) {
+ LOG.trace("Found term occurrence {} with matching existing approved occurrence.", to);
+ to.markApproved();
+ // Annotation without score is considered approved by the frontend
+ element.removeAttr(SCORE_ATTRIBUTE);
+ resultConsumer.accept(to);
} else {
- LOG.trace("The confidence score of occurrence {} is lower than the configured threshold {}.",
- to, scoreThreshold);
+ if (to.getScore() > scoreThreshold) {
+ LOG.trace("Found term occurrence {}.", to);
+ resultConsumer.accept(to);
+ } else {
+ LOG.trace("The confidence score of occurrence {} is lower than the configured threshold {}.", to, scoreThreshold);
+ }
}
+ } catch (InterruptedException e) {
+ LOG.error("Thread interrupted while resolving term occurrences.");
+ Thread.currentThread().interrupt();
+ throw new TermItException(e);
}
});
}
- return result;
}
private Optional resolveAnnotation(Element rdfaElem, Asset> source) {
@@ -226,9 +232,7 @@ private void verifyTermExists(Element rdfaElem, URI termUri, String termId) {
return;
}
if (!termService.exists(termUri)) {
- throw new AnnotationGenerationException(
- "Term with id " + Utils.uriToString(
- termUri) + " denoted by RDFa element '" + rdfaElem + "' not found.");
+ throw new AnnotationGenerationException("Term with id " + Utils.uriToString(termUri) + " denoted by RDFa element '" + rdfaElem + "' not found.");
}
existingTermIds.add(termId);
}
@@ -273,8 +277,8 @@ public boolean supports(Asset> source) {
return true;
}
final Optional probedContentType = documentManager.getContentType(sourceFile);
- return probedContentType.isPresent()
- && (probedContentType.get().equals(MediaType.TEXT_HTML_VALUE)
- || probedContentType.get().equals(MediaType.APPLICATION_XHTML_XML_VALUE));
+ return probedContentType.isPresent() && (probedContentType.get()
+ .equals(MediaType.TEXT_HTML_VALUE) || probedContentType.get()
+ .equals(MediaType.APPLICATION_XHTML_XML_VALUE));
}
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java
index b13049475..a55c7a022 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/SelectorGenerator.java
@@ -57,10 +57,11 @@ default String extractExactText(Element[] elements) {
default StringBuilder extractNodeText(Iterable nodes) {
final StringBuilder sb = new StringBuilder();
for (Node node : nodes) {
- if (!(node instanceof TextNode) && !(node instanceof Element)) {
- continue;
+ if (node instanceof TextNode textNode) {
+ sb.append(textNode.getWholeText());
+ } else if (node instanceof Element elementNode) {
+ sb.append(elementNode.wholeText());
}
- sb.append(node instanceof TextNode ? ((TextNode) node).getWholeText() : ((Element) node).wholeText());
}
return sb;
}
diff --git a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java
index b2fb792a8..767e06676 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/document/html/TextPositionSelectorGenerator.java
@@ -20,9 +20,13 @@
import cz.cvut.kbss.termit.model.selector.TextPositionSelector;
import org.jsoup.nodes.Element;
import org.jsoup.nodes.Node;
+import org.jsoup.nodes.TextNode;
import org.jsoup.select.Elements;
+import org.jsoup.select.NodeTraversor;
+import org.jsoup.select.NodeVisitor;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
/**
* Generates a {@link TextPositionSelector} for the specified elements.
@@ -47,6 +51,14 @@ public TextPositionSelector generateSelector(Element... elements) {
return selector;
}
+ /**
+ * This code was extracted from {@link #extractNodeText} and related functions
+ * to prevent constructing whole string contents for only getting its length.
+ * Now only length is counted from the contents of text nodes.
+ * @see SelectorGenerator#extractNodeText(Iterable)
+ * @see Element#wholeText()
+ * @see TextNode#getWholeText()
+ */
private int resolveStartPosition(Element element) {
final Elements ancestors = element.parents();
Element previous = element;
diff --git a/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java b/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java
index ae8019e24..c6095f424 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/jmx/AppAdminBean.java
@@ -19,7 +19,6 @@
import cz.cvut.kbss.termit.event.EvictCacheEvent;
import cz.cvut.kbss.termit.event.RefreshLastModifiedEvent;
-import cz.cvut.kbss.termit.event.VocabularyContentModified;
import cz.cvut.kbss.termit.rest.dto.HealthInfo;
import cz.cvut.kbss.termit.service.mail.Message;
import cz.cvut.kbss.termit.service.mail.Postman;
@@ -66,7 +65,6 @@ public void invalidateCaches() {
eventPublisher.publishEvent(new EvictCacheEvent(this));
LOG.info("Refreshing last modified timestamps...");
eventPublisher.publishEvent(new RefreshLastModifiedEvent(this));
- eventPublisher.publishEvent(new VocabularyContentModified(this, null));
}
@ManagedOperation(description = "Sends test email to the specified address.")
diff --git a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java
index a9730c702..0f8fede41 100644
--- a/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java
+++ b/src/main/java/cz/cvut/kbss/termit/service/repository/VocabularyRepositoryService.java
@@ -41,6 +41,7 @@
import cz.cvut.kbss.termit.util.Configuration;
import cz.cvut.kbss.termit.util.Constants;
import cz.cvut.kbss.termit.util.Utils;
+import cz.cvut.kbss.termit.util.throttle.CacheableFuture;
import cz.cvut.kbss.termit.workspace.EditableVocabularies;
import jakarta.validation.Validator;
import org.apache.tika.Tika;
@@ -205,7 +206,7 @@ public Vocabulary update(Vocabulary instance) {
}
public Collection getTransitivelyImportedVocabularies(Vocabulary entity) {
- return vocabularyDao.getTransitivelyImportedVocabularies(entity);
+ return vocabularyDao.getTransitivelyImportedVocabularies(entity.getUri());
}
public Set getRelatedVocabularies(Vocabulary entity) {
@@ -319,8 +320,8 @@ private void ensureNoTermRelationsExists(Vocabulary vocabulary) throws AssetRemo
}
}
- public List validateContents(Vocabulary instance) {
- return vocabularyDao.validateContents(instance);
+ public CacheableFuture> validateContents(URI vocabulary) {
+ return vocabularyDao.validateContents(vocabulary);
}
public Integer getTermCount(Vocabulary vocabulary) {
diff --git a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java
index 1b7bcaf11..cf609cab8 100644
--- a/src/main/java/cz/cvut/kbss/termit/util/Configuration.java
+++ b/src/main/java/cz/cvut/kbss/termit/util/Configuration.java
@@ -18,7 +18,9 @@
package cz.cvut.kbss.termit.util;
import cz.cvut.kbss.termit.model.acl.AccessLevel;
+import cz.cvut.kbss.termit.util.throttle.ThrottleAspect;
import jakarta.validation.Valid;
+import jakarta.validation.constraints.Future;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import org.springframework.boot.context.properties.ConfigurationProperties;
@@ -56,6 +58,32 @@ public class Configuration {
* server.
*/
private String jmxBeanName = "TermItAdminBean";
+
+ /**
+ * The number of threads for thread pool executing asynchronous and long-running tasks.
+ * @configurationdoc.default The number of processors available to the Java virtual machine.
+ */
+ @Min(1)
+ private Integer asyncThreadCount = Runtime.getRuntime().availableProcessors();
+
+ /**
+ * The amount of time in which calls of throttled methods
+ * should be merged.
+ * The value must be positive ({@code > 0}).
+ * @configurationdoc.default 10 seconds
+ * @see cz.cvut.kbss.termit.util.throttle.Throttle
+ * @see cz.cvut.kbss.termit.util.throttle.ThrottleAspect
+ */
+ private Duration throttleThreshold = Duration.ofSeconds(10);
+
+ /**
+ * After how much time, should objects with completed futures be discarded.
+ * The value must be positive ({@code > 0}).
+ * @configurationdoc.default 1 minute
+ * @see ThrottleAspect#clearOldFutures()
+ */
+ private Duration throttleDiscardThreshold = Duration.ofMinutes(1);
+
@Valid
private Persistence persistence = new Persistence();
@Valid
@@ -111,6 +139,14 @@ public void setJmxBeanName(String jmxBeanName) {
this.jmxBeanName = jmxBeanName;
}
+ public Integer getAsyncThreadCount() {
+ return asyncThreadCount;
+ }
+
+ public void setAsyncThreadCount(@Min(1) Integer asyncThreadCount) {
+ this.asyncThreadCount = asyncThreadCount;
+ }
+
public Persistence getPersistence() {
return persistence;
}
@@ -263,6 +299,22 @@ public void setTemplate(Template template) {
this.template = template;
}
+ public Duration getThrottleThreshold() {
+ return throttleThreshold;
+ }
+
+ public void setThrottleThreshold(Duration throttleThreshold) {
+ this.throttleThreshold = throttleThreshold;
+ }
+
+ public Duration getThrottleDiscardThreshold() {
+ return throttleDiscardThreshold;
+ }
+
+ public void setThrottleDiscardThreshold(Duration throttleDiscardThreshold) {
+ this.throttleDiscardThreshold = throttleDiscardThreshold;
+ }
+
@Validated
public static class Persistence {
/**
@@ -600,8 +652,6 @@ public static class TextAnalysis {
@Min(8)
private int textQuoteSelectorContextLength = 32;
- private boolean disableVocabularyAnalysisOnTermEdit = false;
-
public String getUrl() {
return url;
}
@@ -625,14 +675,6 @@ public int getTextQuoteSelectorContextLength() {
public void setTextQuoteSelectorContextLength(int textQuoteSelectorContextLength) {
this.textQuoteSelectorContextLength = textQuoteSelectorContextLength;
}
-
- public boolean isDisableVocabularyAnalysisOnTermEdit() {
- return disableVocabularyAnalysisOnTermEdit;
- }
-
- public void setDisableVocabularyAnalysisOnTermEdit(boolean disableVocabularyAnalysisOnTermEdit) {
- this.disableVocabularyAnalysisOnTermEdit = disableVocabularyAnalysisOnTermEdit;
- }
}
@Validated
diff --git a/src/main/java/cz/cvut/kbss/termit/util/Constants.java b/src/main/java/cz/cvut/kbss/termit/util/Constants.java
index fb0959d8f..601c4703f 100644
--- a/src/main/java/cz/cvut/kbss/termit/util/Constants.java
+++ b/src/main/java/cz/cvut/kbss/termit/util/Constants.java
@@ -18,10 +18,12 @@
package cz.cvut.kbss.termit.util;
import cz.cvut.kbss.jopa.vocabulary.SKOS;
+import cz.cvut.kbss.termit.util.throttle.ThrottleAspect;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import java.net.URI;
+import java.time.Duration;
import java.time.Instant;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter;
@@ -207,6 +209,10 @@ public static final class MediaType {
public static final String EXCEL = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
public static final String TURTLE = "text/turtle";
public static final String RDF_XML = "application/rdf+xml";
+
+ private MediaType() {
+ throw new AssertionError();
+ }
}
/**
@@ -244,6 +250,23 @@ private QueryParams() {
}
}
+ public static final class DebouncingGroups {
+
+ /**
+ * Text analysis of all terms in specific vocabulary
+ */
+ public static final String TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS = "TEXT_ANALYSIS_VOCABULARY_TERMS_ALL_DEFINITIONS";
+
+ /**
+ * Text analysis of all vocabularies
+ */
+ public static final String TEXT_ANALYSIS_VOCABULARY = "TEXT_ANALYSIS_VOCABULARY";
+
+ private DebouncingGroups() {
+ throw new AssertionError();
+ }
+ }
+
/**
* the maximum amount of data to buffer when sending messages to a WebSocket session
*/
diff --git a/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java
new file mode 100644
index 000000000..e31b081c7
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/ExceptionUtils.java
@@ -0,0 +1,31 @@
+package cz.cvut.kbss.termit.util;
+
+import org.springframework.lang.NonNull;
+
+import java.util.HashSet;
+import java.util.Set;
+
+public class ExceptionUtils {
+ private ExceptionUtils() {
+ throw new AssertionError();
+ }
+
+ /**
+ * Resolves all nested causes of the {@code throwable} and returns true if any is matching the {@code cause}
+ */
+ public static boolean isCausedBy(final Throwable throwable, @NonNull final Class extends Throwable> cause) {
+ Throwable t = throwable;
+ final Set visited = new HashSet<>();
+ while (t != null) {
+ if(visited.add(t)) {
+ if (cause.isInstance(t)){
+ return true;
+ }
+ t = t.getCause();
+ continue;
+ }
+ break;
+ }
+ return false;
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/Pair.java b/src/main/java/cz/cvut/kbss/termit/util/Pair.java
new file mode 100644
index 000000000..ad0f36a34
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/Pair.java
@@ -0,0 +1,61 @@
+package cz.cvut.kbss.termit.util;
+
+
+import org.springframework.lang.NonNull;
+
+import java.util.Objects;
+
+public class Pair {
+
+ private final T first;
+
+ private final V second;
+
+ public Pair(T first, V second) {
+ this.first = first;
+ this.second = second;
+ }
+
+ public T getFirst() {
+ return first;
+ }
+
+ public V getSecond() {
+ return second;
+ }
+
+
+ /**
+ * First compares the first value, if they are equal, compares the second value.
+ */
+ public static class ComparablePair, V extends java.lang.Comparable>
+ extends Pair implements java.lang.Comparable> {
+
+ public ComparablePair(T first, V second) {
+ super(first, second);
+ }
+
+ @Override
+ public int compareTo(@NonNull Pair.ComparablePair o) {
+ final int firstComparison = this.getFirst().compareTo(o.getFirst());
+ if (firstComparison != 0) {
+ return firstComparison;
+ }
+ return this.getSecond().compareTo(o.getSecond());
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) return true;
+ if (o == null || getClass() != o.getClass()) return false;
+ ComparablePair, ?> that = (ComparablePair, ?>) o;
+ return Objects.equals(getFirst(), that.getFirst()) && Objects.equals(getSecond(), that.getSecond());
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(getFirst(), getSecond());
+ }
+ }
+}
+
diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java
new file mode 100644
index 000000000..d59913ec2
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTask.java
@@ -0,0 +1,43 @@
+package cz.cvut.kbss.termit.util.longrunning;
+
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+
+import java.time.Instant;
+import java.util.Optional;
+import java.util.UUID;
+
+/**
+ * An asynchronously running task that is expected to run for some time.
+ */
+public interface LongRunningTask {
+
+ @Nullable
+ String getName();
+
+ /**
+ * @return true when the task is being actively executed, false otherwise.
+ */
+ boolean isRunning();
+
+ /**
+ * Returns {@code true} if this task completed.
+ *
+ * Completion may be due to normal termination, an exception, or
+ * cancellation -- in all of these cases, this method will return
+ * {@code true}.
+ *
+ * @return {@code true} if this task completed
+ */
+ boolean isDone();
+
+ /**
+ * @return a timestamp of the task execution start,
+ * or empty if the task execution has not yet started.
+ */
+ @NonNull
+ Optional startedAt();
+
+ @NonNull
+ UUID getUuid();
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java
new file mode 100644
index 000000000..d4c396f7c
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskScheduler.java
@@ -0,0 +1,22 @@
+package cz.cvut.kbss.termit.util.longrunning;
+
+import org.springframework.lang.NonNull;
+
+/**
+ * An object that will schedule a long-running tasks
+ * @see LongRunningTask
+ */
+public abstract class LongRunningTaskScheduler {
+ private final LongRunningTasksRegistry registry;
+
+ protected LongRunningTaskScheduler(LongRunningTasksRegistry registry) {
+ this.registry = registry;
+ }
+
+ protected final void notifyTaskChanged(final @NonNull LongRunningTask task) {
+ final String name = task.getName();
+ if (name != null && !name.isBlank()) {
+ registry.onTaskChanged(task);
+ }
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
new file mode 100644
index 000000000..aa4859c61
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTaskStatus.java
@@ -0,0 +1,64 @@
+package cz.cvut.kbss.termit.util.longrunning;
+
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+
+import java.io.Serializable;
+import java.time.Instant;
+import java.time.temporal.ChronoUnit;
+import java.util.Objects;
+import java.util.UUID;
+
+public class LongRunningTaskStatus implements Serializable {
+
+ private final String name;
+
+ private final UUID uuid;
+
+ private final State state;
+
+ private final Instant startedAt;
+
+ public LongRunningTaskStatus(@NonNull LongRunningTask task) {
+ Objects.requireNonNull(task.getName());
+ this.name = task.getName();
+ this.startedAt = task.startedAt().map(time -> time.truncatedTo(ChronoUnit.SECONDS)).orElse(null);
+ this.state = State.of(task);
+ this.uuid = task.getUuid();
+ }
+
+ public @NonNull String getName() {
+ return name;
+ }
+
+ public State getState() {
+ return state;
+ }
+
+ public @Nullable Instant getStartedAt() {
+ return startedAt;
+ }
+
+ public @NonNull UUID getUuid() {
+ return uuid;
+ }
+
+ @Override
+ public String toString() {
+ return "{" + state.name() + (startedAt == null ? "" : ", startedAt=" + startedAt) + ", " + uuid + "}";
+ }
+
+ public enum State {
+ PENDING, RUNNING, DONE;
+
+ public static State of(@NonNull LongRunningTask task) {
+ if (task.isRunning()) {
+ return RUNNING;
+ } else if (task.isDone()) {
+ return DONE;
+ } else {
+ return PENDING;
+ }
+ }
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
new file mode 100644
index 000000000..a73435f4b
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/longrunning/LongRunningTasksRegistry.java
@@ -0,0 +1,64 @@
+package cz.cvut.kbss.termit.util.longrunning;
+
+import cz.cvut.kbss.termit.event.LongRunningTaskChangedEvent;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.context.ApplicationEventPublisher;
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Component;
+
+import java.util.List;
+import java.util.UUID;
+import java.util.concurrent.ConcurrentHashMap;
+
+@Component
+public class LongRunningTasksRegistry {
+
+ private static final Logger LOG = LoggerFactory.getLogger(LongRunningTasksRegistry.class);
+
+ private final ConcurrentHashMap registry = new ConcurrentHashMap<>();
+
+ private final ApplicationEventPublisher eventPublisher;
+
+ @Autowired
+ public LongRunningTasksRegistry(ApplicationEventPublisher eventPublisher) {
+ this.eventPublisher = eventPublisher;
+ }
+
+ public void onTaskChanged(@NonNull final LongRunningTask task) {
+ final LongRunningTaskStatus status = new LongRunningTaskStatus(task);
+
+ if (LOG.isTraceEnabled()) {
+ LOG.atTrace().setMessage("Long running task changed state: {}{}").addArgument(status::getName)
+ .addArgument(status).log();
+ }
+
+ handleTaskChanged(task);
+ eventPublisher.publishEvent(new LongRunningTaskChangedEvent(this, status));
+ }
+
+ private void handleTaskChanged(@NonNull final LongRunningTask task) {
+ if(task.isDone()) {
+ registry.remove(task.getUuid());
+ } else {
+ registry.put(task.getUuid(), task);
+ }
+
+ // perform cleanup
+ registry.forEach((key, value) -> {
+ if (value.isDone()) {
+ registry.remove(key);
+ }
+ });
+
+ if (LOG.isTraceEnabled() && registry.isEmpty()) {
+ LOG.trace("All long running tasks completed");
+ }
+ }
+
+ @NonNull
+ public List getTasks() {
+ return registry.values().stream().map(LongRunningTaskStatus::new).toList();
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java
new file mode 100644
index 000000000..6af5651d5
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/CacheableFuture.java
@@ -0,0 +1,46 @@
+package cz.cvut.kbss.termit.util.throttle;
+
+import cz.cvut.kbss.termit.exception.TermItException;
+import org.springframework.lang.Nullable;
+
+import java.util.Optional;
+import java.util.concurrent.ExecutionException;
+import java.util.concurrent.Future;
+
+/**
+ * A future which can provide a cached result before its completion.
+ * @see Future
+ */
+public interface CacheableFuture extends ChainableFuture {
+
+ /**
+ * @return the cached result when available
+ */
+ Optional getCachedResult();
+
+ /**
+ * Sets possible cached result
+ *
+ * @param cachedResult the result to set, or null to clear the cache
+ * @return self
+ */
+ CacheableFuture setCachedResult(@Nullable final T cachedResult);
+
+ /**
+ * @return the future result if it is available, cached result otherwise.
+ */
+ default Optional getNow() {
+ try {
+ if (isDone() && !isCancelled()) {
+ return Optional.of(get());
+ }
+ } catch (ExecutionException e) {
+ throw new TermItException(e);
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new TermItException(e);
+ }
+
+ return getCachedResult();
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java
new file mode 100644
index 000000000..0d8b63d6c
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ChainableFuture.java
@@ -0,0 +1,16 @@
+package cz.cvut.kbss.termit.util.throttle;
+
+import java.util.concurrent.Future;
+import java.util.function.Consumer;
+
+public interface ChainableFuture extends Future {
+
+ /**
+ * Executes this action once the future is completed normally.
+ * Action is not executed on exceptional completion.
+ *
+ * If the future is already completed, action is executed synchronously.
+ * @param action action to be executed
+ */
+ ChainableFuture then(Consumer action);
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java
new file mode 100644
index 000000000..74b31b905
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/SynchronousTransactionExecutor.java
@@ -0,0 +1,22 @@
+package cz.cvut.kbss.termit.util.throttle;
+
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.concurrent.Executor;
+
+/**
+ * Executes the runnable in a transaction synchronously.
+ *
+ * @see Transactional
+ */
+@Component
+public class SynchronousTransactionExecutor implements Executor {
+
+ @Transactional
+ @Override
+ public void execute(@NonNull Runnable command) {
+ command.run();
+ }
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
new file mode 100644
index 000000000..cc9c9080b
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/Throttle.java
@@ -0,0 +1,105 @@
+package cz.cvut.kbss.termit.util.throttle;
+
+import cz.cvut.kbss.termit.util.Constants;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+
+import java.lang.annotation.Documented;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+import java.util.concurrent.Future;
+
+/**
+ * Indicates that calls to this method will be throttled & debounced.
+ *
+ * The task created from the method will be executed on the first call of the method,
+ * then every next call which comes earlier than {@link Constants#THROTTLE_THRESHOLD}
+ * will return a pending future which might be resolved by a newer call.
+ * Future will be resolved once per {@link Constants#THROTTLE_THRESHOLD} (+ duration to execute the future).
+ *
+ *
+ *
+ * Call to this method cannot be part of an existing transaction.
+ * If {@link org.springframework.transaction.annotation.Transactional @Transactional} is present with this annotation,
+ * new transaction is created for the task execution.
+ *
+ * Available only for methods returning {@code void}, {@link Void} and {@link ThrottledFuture},
+ * method signature may be {@link Future},
+ * or another type assignable from {@link ThrottledFuture},
+ * but the returned concrete object has to be {@link ThrottledFuture}, method call will throw otherwise!
+ *
+ * Whole body of method with {@code void} or {@link Void} return types will be considered as task which will be executed later.
+ * In case of {@link Future} return type, only task in returned {@link ThrottledFuture} is throttled,
+ * meaning that actual body of the method will be executed every call.
+ *
+ * Note that returned future can be canceled
+ *
+ * Method may also return already canceled or fulfilled future; in that case, the result is returned immediately.
+ *
+ * Example implementation:
+ *
+ * {@code @}Throttle(value = "{#paramObj, #anotherParam}")
+ * public Future<String> myFunction(Object paramObj, Object anotherParam) {
+ * // this will execute on every call as the return type is future
+ * LOG.trace("my function called");
+ * return ThrottledFuture.of(() -> doStuff()); // doStuff() will be throttled
+ * }
+ *
+ *
+ * {@code @}Throttle(value = "{#paramObj, #anotherParam}")
+ * public void myFunction(Object paramObj, Object anotherParam) {
+ * // whole method body will be throttled, as return type is not future
+ * LOG.trace("my function called");
+ * }
+ *
+ *
+ * @implNote Methods will be called from a separated thread.
+ * @see Debouncing and Throttling
+ * @see Throttling + debouncing image
+ */
+@Target(ElementType.METHOD)
+@Retention(RetentionPolicy.RUNTIME)
+@Documented
+public @interface Throttle {
+
+ /**
+ * The Spring-EL expression
+ * returning a List of Objects or a String which will be used to construct the unique identifier
+ * for this throttled instance.
+ */
+ @NonNull String value() default "";
+
+ /**
+ * The Spring-EL expression
+ * returning group identifier a List of Objects or a String to which this throttle belongs.
+ *
+ * When there is a pending task P with a group
+ * that is also a prefix for a group of a new task N,
+ * the new task N will be canceled immediately.
+ * The group of the task P is lower than the group of the task N.
+ *
+ * When a task with lower group is scheduled, all scheduled tasks with higher groups are canceled.
+ *
+ * Example:
+ *
+ * new task A with group "my.group.task1" is scheduled
+ * new task B with group "my.group.task1.subtask" wants to be scheduled
+ * -> task B is canceled immediately (task A with lower group is already pending)
+ * new task C with group "my.group" is scheduled
+ * -> task A is canceled as the task C has lower group than A
+ *
+ * Blank string disables any group processing.
+ * @see String#compareTo(String)
+ */
+ @NonNull String group() default "";
+
+ /**
+ * @return a key name of the task which is displayed on the frontend.
+ * Example: {@code name = "validation"} on frontend a translatable name with a key
+ * {@code "longrunningtasks.name.validation"} is displayed.
+ * Leave blank to hide the task on the frontend.
+ */
+ @Nullable String name() default "";
+}
diff --git a/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
new file mode 100644
index 000000000..fcf8ea14b
--- /dev/null
+++ b/src/main/java/cz/cvut/kbss/termit/util/throttle/ThrottleAspect.java
@@ -0,0 +1,611 @@
+package cz.cvut.kbss.termit.util.throttle;
+
+import cz.cvut.kbss.termit.TermItApplication;
+import cz.cvut.kbss.termit.exception.TermItException;
+import cz.cvut.kbss.termit.exception.ThrottleAspectException;
+import cz.cvut.kbss.termit.util.Configuration;
+import cz.cvut.kbss.termit.util.Pair;
+import cz.cvut.kbss.termit.util.longrunning.LongRunningTaskScheduler;
+import cz.cvut.kbss.termit.util.longrunning.LongRunningTasksRegistry;
+import org.aspectj.lang.JoinPoint;
+import org.aspectj.lang.ProceedingJoinPoint;
+import org.aspectj.lang.reflect.MethodSignature;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.context.annotation.Profile;
+import org.springframework.context.annotation.Scope;
+import org.springframework.core.annotation.Order;
+import org.springframework.expression.EvaluationContext;
+import org.springframework.expression.EvaluationException;
+import org.springframework.expression.Expression;
+import org.springframework.expression.ExpressionParser;
+import org.springframework.expression.spel.standard.SpelExpressionParser;
+import org.springframework.expression.spel.support.DataBindingPropertyAccessor;
+import org.springframework.expression.spel.support.StandardEvaluationContext;
+import org.springframework.expression.spel.support.StandardTypeLocator;
+import org.springframework.lang.NonNull;
+import org.springframework.lang.Nullable;
+import org.springframework.scheduling.TaskScheduler;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.stereotype.Component;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.time.Clock;
+import java.time.Duration;
+import java.time.Instant;
+import java.util.Arrays;
+import java.util.Collection;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.NavigableMap;
+import java.util.Objects;
+import java.util.Optional;
+import java.util.Set;
+import java.util.TreeMap;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.function.Supplier;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import static org.springframework.beans.factory.config.ConfigurableBeanFactory.SCOPE_SINGLETON;
+
+/**
+ * @see Throttle
+ * @implNote The aspect is configured in {@code spring-aop.xml}, this uses Spring AOP instead of AspectJ.
+ */
+@Order
+@Scope(SCOPE_SINGLETON)
+@Component("throttleAspect")
+@Profile("!test")
+public class ThrottleAspect extends LongRunningTaskScheduler {
+
+ private static final Logger LOG = LoggerFactory.getLogger(ThrottleAspect.class);
+
+ /**
+ *
Throttled futures are returned as results of method calls.
+ *
Tasks inside them can be replaced by a newer ones allowing
+ * to merge multiple (throttled) method calls into a single one while always executing the newest one possible.
+ *
A task inside a throttled future represents
+ * a heavy/long-running task acquired from the body of an throttled method
+ *
+ * @implSpec Synchronize in the field declaration order before modification
+ */
+ private final Map> throttledFutures;
+
+ /**
+ * The last run is updated every time a task is finished.
+ * @implSpec Synchronize in the field declaration order before modification
+ */
+ private final Map lastRun;
+
+ /**
+ * Scheduled futures are returned from {@link #taskScheduler}.
+ * Futures are completed by execution of tasks created in {@link #createRunnableToSchedule}.
+ * Records about them are used for their cancellation in case of debouncing.
+ *
+ * @implSpec Synchronize in the field declaration order before modification
+ */
+ private final NavigableMap> scheduledFutures;
+
+ /**
+ * Thread safe set holding identifiers of threads that are
+ * currently executing a throttled task.
+ */
+ private final Set throttledThreads = ConcurrentHashMap.newKeySet();
+
+ /**
+ * Parser for Spring Expression Language
+ */
+ private final ExpressionParser parser = new SpelExpressionParser();
+
+ private final TaskScheduler taskScheduler;
+
+ /**
+ * A base context for evaluation of SpEL expressions
+ */
+ private final StandardEvaluationContext standardEvaluationContext;
+
+ /**
+ * Used for acquiring {@link #lastRun} timestamps.
+ * @implNote for testing purposes
+ */
+ private final Clock clock;
+
+ /**
+ * Wrapper for executions in a transaction context
+ */
+ private final SynchronousTransactionExecutor transactionExecutor;
+
+ /**
+ * A timestamp of the last time maps were cleaned.
+ * The reference might be null.
+ * @see #clearOldFutures()
+ */
+ private final AtomicReference lastClear;
+
+ private final Configuration configuration;
+
+ @Autowired
+ public ThrottleAspect(@Qualifier("longRunningTaskScheduler") TaskScheduler taskScheduler,
+ SynchronousTransactionExecutor transactionExecutor,
+ LongRunningTasksRegistry longRunningTasksRegistry, Configuration configuration) {
+ super(longRunningTasksRegistry);
+ this.taskScheduler = taskScheduler;
+ this.transactionExecutor = transactionExecutor;
+ this.configuration = configuration;
+ throttledFutures = new HashMap<>();
+ lastRun = new HashMap<>();
+ scheduledFutures = new TreeMap<>();
+ clock = Clock.systemUTC(); // used by Instant.now() by default
+ standardEvaluationContext = makeDefaultContext();
+ lastClear = new AtomicReference<>(Instant.now(clock));
+ }
+
+ /**
+ * Constructor for testing environment
+ */
+ protected ThrottleAspect(Map> throttledFutures,
+ Map lastRun,
+ NavigableMap> scheduledFutures, TaskScheduler taskScheduler,
+ Clock clock, SynchronousTransactionExecutor transactionExecutor,
+ LongRunningTasksRegistry longRunningTasksRegistry, Configuration configuration) {
+ super(longRunningTasksRegistry);
+ this.throttledFutures = throttledFutures;
+ this.lastRun = lastRun;
+ this.scheduledFutures = scheduledFutures;
+ this.taskScheduler = taskScheduler;
+ this.clock = clock;
+ this.transactionExecutor = transactionExecutor;
+ this.configuration = configuration;
+ standardEvaluationContext = makeDefaultContext();
+ lastClear = new AtomicReference<>(Instant.now(clock));
+ }
+
+ private static StandardEvaluationContext makeDefaultContext() {
+ StandardEvaluationContext standardEvaluationContext = new StandardEvaluationContext();
+ standardEvaluationContext.addPropertyAccessor(DataBindingPropertyAccessor.forReadOnlyAccess());
+
+ final ClassLoader loader = ThrottleAspect.class.getClassLoader();
+ final StandardTypeLocator typeLocator = new StandardTypeLocator(loader);
+
+ final String basePackage = TermItApplication.class.getPackageName();
+ Arrays.stream(loader.getDefinedPackages()).map(Package::getName).filter(s -> s.indexOf(basePackage) == 0)
+ .forEach(typeLocator::registerImport);
+
+ standardEvaluationContext.setTypeLocator(typeLocator);
+ return standardEvaluationContext;
+ }
+
+ /**
+ * @return future or null
+ * @throws TermItException when the target method throws
+ * @throws IllegalCallerException when the annotated method returns another type than {@code void}, {@link Void} or {@link Future}
+ * @implNote Around advice configured in {@code spring-aop.xml}
+ */
+ public @Nullable Object throttleMethodCall(@NonNull ProceedingJoinPoint joinPoint,
+ @NonNull Throttle throttleAnnotation) throws Throwable {
+
+ // if the current thread is already executing a throttled code, we want to skip further throttling
+ if (throttledThreads.contains(Thread.currentThread().getId())) {
+ // proceed with method execution
+ final Object result = joinPoint.proceed();
+ if (result instanceof ThrottledFuture> throttledFuture) {
+ // directly run throttled future
+ throttledFuture.run(null);
+ return throttledFuture;
+ }
+ return result;
+ }
+
+ return doThrottle(joinPoint, throttleAnnotation);
+ }
+
+ private synchronized @Nullable Object doThrottle(@NonNull ProceedingJoinPoint joinPoint,
+ @NonNull Throttle throttleAnnotation) throws Throwable {
+
+ final MethodSignature signature = (MethodSignature) joinPoint.getSignature();
+
+ // construct the throttle instance key
+ final Identifier identifier = makeIdentifier(joinPoint, throttleAnnotation);
+ LOG.trace("Throttling task with key '{}'", identifier);
+
+ synchronized (scheduledFutures) {
+ if (!identifier.getGroup().isBlank()) {
+ // check if there is a task with lower group
+ // and if so, cancel this task in favor of the lower group
+ final Map.Entry> lowerEntry = scheduledFutures.lowerEntry(identifier);
+ if (lowerEntry != null) {
+ final Future