diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/authorization/AuthorizedRequest.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/authorization/AuthorizedRequest.java index e95d35704cb..f1969d08847 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/authorization/AuthorizedRequest.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/authorization/AuthorizedRequest.java @@ -75,11 +75,12 @@ public R execute(ServiceProvider context) { user = User.SYSTEM; } else { // if there is authentication configured, but no authorization token found prevent execution and throw UnauthorizedException + Request request = Iterables.getFirst(requests, null); if (PlatformUtil.isDevVersion()) { - Request request = Iterables.getFirst(requests, null); System.err.println(request); } - throw new UnauthorizedException("Missing authorization token"); + throw new UnauthorizedException("Missing authorization token") + .withDeveloperMessage("Unable to execute request '%s' without a proper authorization token. Supply one either as a standard HTTP Authorization header or via the token query parameter.", request.getType()); } } else { // verify authorization header value diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/compare/AnalysisCompareResult.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/compare/AnalysisCompareResult.java index 14f2e1ce890..46f0ca88a18 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/compare/AnalysisCompareResult.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/compare/AnalysisCompareResult.java @@ -24,6 +24,10 @@ import com.b2international.snowowl.core.ResourceURIWithQuery; import com.b2international.snowowl.core.domain.ListCollectionResource; +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.annotation.JsonSetter; import com.google.common.base.MoreObjects; /** @@ -62,7 +66,11 @@ public AnalysisCompareResult(final ResourceURIWithQuery fromUri, final ResourceU * @param fromUri - the resource URI representing the comparison baseline * @param toUri - the resource URI representing the comparison target */ - public AnalysisCompareResult(final List items, final ResourceURIWithQuery fromUri, final ResourceURIWithQuery toUri) { + @JsonCreator + public AnalysisCompareResult( + @JsonProperty("items") final List items, + @JsonProperty("fromUri") final ResourceURIWithQuery fromUri, + @JsonProperty("toUri") final ResourceURIWithQuery toUri) { super(items); this.fromUri = checkNotNull(fromUri, "Resource URI 'fromUri' may not be null."); this.toUri = checkNotNull(toUri, "Resource URI 'toUri' may not be null."); @@ -86,6 +94,12 @@ public List getCounters() { return counters; } + @JsonSetter + void setCounters(List counters) { + this.counters = counters; + } + + @JsonIgnore public Integer getTotalChanges() { return getCounterValue(COUNTER_TOTAL); } @@ -93,10 +107,12 @@ public Integer getTotalChanges() { /** * @return the number of added primary components between the two points of reference */ + @JsonIgnore public Integer getNewComponents() { return getCounterValue(COUNTER_NEW_COMPONENTS); } + @JsonIgnore public void setNewComponents(final Integer newComponents) { setCounterValue(COUNTER_NEW_COMPONENTS, newComponents); setCounterValue(COUNTER_TOTAL, Optional.ofNullable(getCounterValue(COUNTER_TOTAL)).orElse(0) + newComponents); @@ -105,10 +121,12 @@ public void setNewComponents(final Integer newComponents) { /** * @return the number of changed primary components between the two points of reference */ + @JsonIgnore public Integer getChangedComponents() { return getCounterValue(COUNTER_CHANGED_COMPONENTS); } + @JsonIgnore public void setChangedComponents(final Integer changedComponents) { setCounterValue(COUNTER_CHANGED_COMPONENTS, changedComponents); setCounterValue(COUNTER_TOTAL, Optional.ofNullable(getCounterValue(COUNTER_TOTAL)).orElse(0) + changedComponents); @@ -117,10 +135,12 @@ public void setChangedComponents(final Integer changedComponents) { /** * @return the number of removed primary components between the two points of reference */ + @JsonIgnore public Integer getDeletedComponents() { return getCounterValue(COUNTER_DELETED_COMPONENTS); } + @JsonIgnore public void setDeletedComponents(final Integer deletedComponents) { setCounterValue(COUNTER_DELETED_COMPONENTS, deletedComponents); setCounterValue(COUNTER_TOTAL, Optional.ofNullable(getCounterValue(COUNTER_TOTAL)).orElse(0) + deletedComponents); diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/AsyncRequest.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/AsyncRequest.java index 721b05ada3d..1bd13aa152e 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/AsyncRequest.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/events/AsyncRequest.java @@ -18,8 +18,10 @@ import java.util.HashMap; import java.util.Map; import java.util.concurrent.TimeUnit; +import java.util.function.Predicate; import com.b2international.snowowl.core.ServiceProvider; +import com.b2international.snowowl.core.api.SnowowlRuntimeException; import com.b2international.snowowl.core.events.util.Promise; import com.b2international.snowowl.core.identity.User; import com.b2international.snowowl.core.jobs.JobRequests; @@ -144,6 +146,16 @@ public R execute(ServiceProvider context) { return getRequest().execute(context); } + /** + * Helper to run a fully prepared async request inside a job and store its result. + * + * @return a {@link ScheduleJobRequestBuilder} with only the request part configured to this {@link AsyncRequest} + */ + public ScheduleJobRequestBuilder runAsJob() { + return JobRequests.prepareSchedule() + .setRequest(this); + } + /** * Wraps the this {@link AsyncRequest}'s {@link #getRequest()} into a {@link ScheduleJobRequestBuilder} and prepares for execution. * @@ -151,11 +163,10 @@ public R execute(ServiceProvider context) { * @return the prepared {@link AsyncRequest} that will schedule the request as a job and return the job ID as a result */ public AsyncRequest runAsJob(String description) { - return JobRequests.prepareSchedule() + return runAsJob() .setDescription(description) - .setRequest(this) .buildAsync(); - } + } /** * Wraps the this {@link AsyncRequest}'s {@link #getRequest()} into a {@link ScheduleJobRequestBuilder} and prepares for execution. @@ -165,10 +176,9 @@ public AsyncRequest runAsJob(String description) { * @return the prepared {@link AsyncRequest} that will schedule the request as a job and return the job ID as a result */ public AsyncRequest runAsJob(String jobKey, String description) { - return JobRequests.prepareSchedule() + return runAsJob() .setKey(jobKey) .setDescription(description) - .setRequest(this) .buildAsync(); } @@ -181,12 +191,34 @@ public AsyncRequest runAsJob(String jobKey, String description) { * @return the prepared {@link AsyncRequest} that will schedule the request as a job and return the job ID as a result */ public AsyncRequest runAsJobWithRestart(String jobKey, String description) { - return JobRequests.prepareSchedule() + return runAsJob() .setKey(jobKey) .setDescription(description) - .setRequest(this) .setRestart(true) .buildAsync(); } + /** + * A simple poller implementation that reuses this prepared {@link AsyncRequest} until a certain condition is met in the response object. + * + * @param bus - the bus to use for request execution + * @param pollIntervalMillis - the polling interval between retries + * @param canFinish + * @return + */ + public Promise retryUntil(IEventBus bus, long pollIntervalMillis, Predicate canFinish) { + return execute(bus).thenWith(result -> { + if (canFinish.test(result)) { + return Promise.immediate(result); + } else { + try { + Thread.sleep(pollIntervalMillis); + } catch (InterruptedException e) { + throw new SnowowlRuntimeException(e); + } + return retryUntil(bus, pollIntervalMillis, canFinish); + } + }); + } + } \ No newline at end of file diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/RemoteJobTracker.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/RemoteJobTracker.java index 83afbbc93b5..207f514ca00 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/RemoteJobTracker.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/RemoteJobTracker.java @@ -30,7 +30,10 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import com.b2international.index.*; +import com.b2international.index.BulkUpdate; +import com.b2international.index.Hits; +import com.b2international.index.Index; +import com.b2international.index.Searcher; import com.b2international.index.aggregations.Aggregation; import com.b2international.index.aggregations.AggregationBuilder; import com.b2international.index.query.Expression; @@ -112,6 +115,34 @@ private Hits searchHits(Expression query, List fields, S }); } + public String schedule(RemoteJob job) { + + // first register doc + final String jobId = job.getId(); + LOG.trace("Scheduled job {}", jobId); + + // try to convert the request to a param object + String parameters; + try { + parameters = mapper.writeValueAsString(job.getParameters(mapper)); + } catch (Throwable e) { + parameters = ""; + } + put(RemoteJobEntry.builder() + .id(jobId) + .key(job.getKey()) + .description(job.getDescription()) + .user(job.getUser()) + .parameters(parameters) + .scheduleDate(new Date()) + .build()); + + // schedule the job after we successfully wrote the job doc into the index + // previously this was done through a listener which was executed synchronously before the job actually stated, see IJobChangeListener.scheduled(...) + job.schedule(); + + return jobId; + } public void requestCancel(String jobId) { final RemoteJobEntry job = get(jobId); @@ -297,27 +328,7 @@ private class RemoteJobChangeAdapter extends JobChangeAdapter { @Override public void scheduled(IJobChangeEvent event) { - if (event.getJob() instanceof RemoteJob) { - final RemoteJob job = (RemoteJob) event.getJob(); - final String jobId = job.getId(); - LOG.trace("Scheduled job {}", jobId); - // try to convert the request to a param object - String parameters; - try { - parameters = mapper.writeValueAsString(job.getParameters(mapper)); - } catch (Throwable e) { - parameters = ""; - } - put(RemoteJobEntry.builder() - .id(jobId) - .key(job.getKey()) - .description(job.getDescription()) - .user(job.getUser()) - .parameters(parameters) - .scheduleDate(new Date()) - .build()); - - } + // handled by the ScheduleJobRequest.execute(...) logic which calls the scheduled(RemoteJob) method on this tracker } @Override diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequest.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequest.java index 4e8be4e570b..59987c85d06 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequest.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequest.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2022 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2017-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -24,6 +24,7 @@ import org.hibernate.validator.constraints.NotEmpty; import com.b2international.commons.exceptions.AlreadyExistsException; +import com.b2international.commons.exceptions.BadRequestException; import com.b2international.commons.exceptions.ConflictException; import com.b2international.snowowl.core.ServiceProvider; import com.b2international.snowowl.core.events.Request; @@ -59,10 +60,12 @@ final class ScheduleJobRequest implements Request { private final boolean autoClean; private final boolean restart; + + private final boolean cached; private final SerializableSchedulingRule schedulingRule; - ScheduleJobRequest(String key, String user, String description, Request request, SerializableSchedulingRule schedulingRule, boolean autoClean, boolean restart) { + ScheduleJobRequest(String key, String user, String description, Request request, SerializableSchedulingRule schedulingRule, boolean autoClean, boolean restart, boolean cached) { this.key = key; this.user = user; this.request = request; @@ -70,11 +73,16 @@ final class ScheduleJobRequest implements Request { this.schedulingRule = schedulingRule; this.autoClean = autoClean; this.restart = restart; + this.cached = cached; } @Override public String execute(ServiceProvider context) { + if (cached && autoClean) { + throw new BadRequestException("Automatically cleaned jobs cannot be cached."); + } + final String id = IDs.sha1(key); try { @@ -89,15 +97,23 @@ public String execute(ServiceProvider context) { if (existingJob.isPresent()) { RemoteJobEntry job = existingJob.get(); - - // if running, fail - if (!job.isDone()) { - throw new AlreadyExistsException(String.format("Job[%s]", request.getType()), key); - } - - // if restart not requested, fail - if (!restart) { - throw new ConflictException("An existing job is present with the same '%s' key. Request 'restart' if the previous job can be safely overriden.", key); + + // if cached reuse + if (cached) { + // use an existing running job entry until it is not done or when restart not requested, which kinda forms an asynchronous cache for any request + if (!job.isDeleted() && (!job.isDone() || !restart)) { + return id; + } + } else { + // if running, fail + if (!job.isDone()) { + throw new AlreadyExistsException(String.format("Job[%s]", request.getType()), key); + } + + // if restart not requested, fail + if (!restart) { + throw new ConflictException("An existing job is present with the same '%s' key. Request 'restart' if the previous job can be safely overriden.", key); + } } // otherwise delete the existing job and create a new one using the same key @@ -113,8 +129,7 @@ public String execute(ServiceProvider context) { if (schedulingRule != null) { job.setRule(schedulingRule); } - job.schedule(); - return id; + return context.service(RemoteJobTracker.class).schedule(job); } finally { SCHEDULE_LOCK.release(); } diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequestBuilder.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequestBuilder.java index 34dfa4b1ad7..d9dce3204be 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequestBuilder.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/jobs/ScheduleJobRequestBuilder.java @@ -1,5 +1,5 @@ /* - * Copyright 2017-2020 B2i Healthcare Pte Ltd, http://b2i.sg + * Copyright 2017-2023 B2i Healthcare Pte Ltd, http://b2i.sg * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,7 +21,9 @@ import com.b2international.snowowl.core.events.AsyncRequest; import com.b2international.snowowl.core.events.BaseRequestBuilder; import com.b2international.snowowl.core.events.Request; +import com.b2international.snowowl.core.events.util.Promise; import com.b2international.snowowl.core.request.SystemRequestBuilder; +import com.b2international.snowowl.eventbus.IEventBus; /** * A request builder that wraps existing {@link Request} instances to run them as jobs. @@ -37,6 +39,7 @@ public final class ScheduleJobRequestBuilder extends BaseRequestBuilder doBuild() { - return new ScheduleJobRequest(key, user, description, request, schedulingRule, autoClean, restart); + return new ScheduleJobRequest(key, user, description, request, schedulingRule, autoClean, restart, cached); + } + + /** + * Schedules the job described by this builder and then polls using the given interval of milliseconds until it is not complete. + * + * @param context + * @param pollIntervalMillis + * @return + */ + public Promise waitFor(ServiceProvider context, long pollIntervalMillis) { + return buildAsync() + .executeWithContext(context) + .then(jobId -> JobRequests.prepareGet(jobId).buildAsync().withContext(context)) + .thenWith(req -> req.retryUntil(context.service(IEventBus.class), pollIntervalMillis, RemoteJobEntry::isDone)); } } diff --git a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/request/resource/DependencyCompareRequest.java b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/request/resource/DependencyCompareRequest.java index 7a66418fbde..f3709a383f6 100644 --- a/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/request/resource/DependencyCompareRequest.java +++ b/core/com.b2international.snowowl.core/src/com/b2international/snowowl/core/request/resource/DependencyCompareRequest.java @@ -28,6 +28,7 @@ import com.b2international.snowowl.core.events.Request; import com.b2international.snowowl.core.request.RepositoryRequest; import com.b2international.snowowl.core.request.ResourceRequests; +import com.fasterxml.jackson.annotation.JsonProperty; /** * @since 9.0 @@ -36,12 +37,15 @@ final class DependencyCompareRequest implements Request