org.kie.kogito.jobs.service.scheduler.BaseTimerJobScheduler Maven / Gradle / Ivy
/*
* Copyright 2020 Red Hat, Inc. and/or its affiliates.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.kie.kogito.jobs.service.scheduler;
import java.time.Duration;
import java.time.ZonedDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.concurrent.ConcurrentHashMap;
import java.util.function.Consumer;
import org.eclipse.microprofile.reactive.streams.operators.PublisherBuilder;
import org.eclipse.microprofile.reactive.streams.operators.ReactiveStreams;
import org.kie.kogito.jobs.service.model.JobExecutionResponse;
import org.kie.kogito.jobs.service.model.JobStatus;
import org.kie.kogito.jobs.service.model.job.JobDetails;
import org.kie.kogito.jobs.service.model.job.ManageableJobHandle;
import org.kie.kogito.jobs.service.repository.ReactiveJobRepository;
import org.kie.kogito.jobs.service.utils.DateUtil;
import org.kie.kogito.timer.JobHandle;
import org.kie.kogito.timer.Trigger;
import org.kie.kogito.timer.impl.PointInTimeTrigger;
import org.reactivestreams.Publisher;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Base reactive Job Scheduler that performs the fundamental operations and let to the concrete classes to
* implement the scheduling actions.
*/
public abstract class BaseTimerJobScheduler implements ReactiveJobScheduler {
private static final Logger LOGGER = LoggerFactory.getLogger(BaseTimerJobScheduler.class);
long backoffRetryMillis;
long maxIntervalLimitToRetryMillis;
/**
* Flag to allow and force a job with expirationTime in the past to be executed immediately. If false an
* exception will be thrown.
*/
Optional forceExecuteExpiredJobs;
/**
* The current chunk size in minutes the scheduler handles, it is used to keep a limit number of jobs scheduled
* in the in-memory scheduler.
*/
long schedulerChunkInMinutes;
private ReactiveJobRepository jobRepository;
private final Map schedulerControl;
protected BaseTimerJobScheduler() {
this(null, 0, 0, 0, null);
}
public BaseTimerJobScheduler(ReactiveJobRepository jobRepository,
long backoffRetryMillis,
long maxIntervalLimitToRetryMillis,
long schedulerChunkInMinutes,
Boolean forceExecuteExpiredJobs) {
this.jobRepository = jobRepository;
this.backoffRetryMillis = backoffRetryMillis;
this.maxIntervalLimitToRetryMillis = maxIntervalLimitToRetryMillis;
this.schedulerControl = new ConcurrentHashMap<>();
this.schedulerChunkInMinutes = schedulerChunkInMinutes;
this.forceExecuteExpiredJobs = Optional.ofNullable(forceExecuteExpiredJobs);
}
@Override
public Publisher schedule(JobDetails job) {
LOGGER.debug("Scheduling {}", job);
return ReactiveStreams
//check if the job is already scheduled and persisted
.fromCompletionStage(jobRepository.exists(job.getId()))
.flatMap(exists -> Boolean.TRUE.equals(exists)
? handleExistingJob(job)
: ReactiveStreams.of(job))
.flatMap(j -> isOnCurrentSchedulerChunk(job)
//in case the job is on the current bulk, proceed with scheduling process
? doJobScheduling(job)
//in case the job is not on the current bulk, just save it to be scheduled later
: ReactiveStreams.fromCompletionStage(jobRepository.save(jobWithStatus(job, JobStatus.SCHEDULED))))
.buildRs();
}
@Override
public PublisherBuilder reschedule(String id, Trigger trigger) {
return ReactiveStreams.fromCompletionStageNullable(jobRepository.merge(id, JobDetails.builder().trigger(trigger).build()))
.peek(this::doCancel)
.map(this::schedule)
.flatMapRsPublisher(j -> j);
}
private JobDetails jobWithStatus(JobDetails job, JobStatus status) {
return JobDetails.builder().of(job).status(status).build();
}
private JobDetails jobWithStatusAndHandle(JobDetails job, JobStatus status, ManageableJobHandle handle) {
return JobDetails.builder().of(job).status(status).scheduledId(String.valueOf(handle.getId())).build();
}
/**
* Performs the given job scheduling process on the scheduler, after all the validations already made.
* @param job to be scheduled
* @return
*/
private PublisherBuilder doJobScheduling(JobDetails job) {
return ReactiveStreams.of(job)
//calculate the delay (when the job should be executed)
.map(current -> job.getTrigger().hasNextFireTime())
.map(DateUtil::fromDate)
.map(this::calculateDelay)
.peek(delay -> Optional
.of(delay.isNegative())
.filter(Boolean.FALSE::equals)
.orElseThrow(() -> new RuntimeException("The expirationTime should be greater than current " +
"time")))
//schedule the job on the scheduler
.map(delay -> scheduleRegistering(job, Optional.empty()))
.flatMap(p -> p)
.map(handle -> jobWithStatusAndHandle(job, JobStatus.SCHEDULED, handle))
.map(scheduledJob -> jobRepository.save(scheduledJob))
.flatMapCompletionStage(p -> p);
}
/**
* Check if it should be scheduled (on the current chunk) or saved to be scheduled later.
* @return
*/
private boolean isOnCurrentSchedulerChunk(JobDetails job) {
return DateUtil.fromDate(job.getTrigger().hasNextFireTime()).isBefore(DateUtil.now().plusMinutes(schedulerChunkInMinutes));
}
private PublisherBuilder handleExistingJob(JobDetails job) {
//always returns true, canceling in case the job is already schedule
return ReactiveStreams.fromCompletionStage(jobRepository.get(job.getId()))
//handle scheduled and retry cases
.flatMap(
j -> {
switch (j.getStatus()) {
case SCHEDULED:
return handleExpirationTime(j)
.map(scheduled -> jobWithStatus(scheduled, JobStatus.CANCELED))
.map(CompletableFuture::completedFuture)
.flatMapCompletionStage(this::cancel)
.map(deleted -> j);
case RETRY:
return handleRetry(CompletableFuture.completedFuture(j))
.flatMap(retryJob -> ReactiveStreams.empty());
default:
//empty to break the stream processing
return ReactiveStreams.empty();
}
})
.onErrorResumeWith(t -> ReactiveStreams.empty());
}
private Duration calculateDelay(ZonedDateTime expirationTime) {
//in case forceExecuteExpiredJobs is true, execute the job immediately (1ms)
return Optional.of(Duration.between(DateUtil.now(), expirationTime))
.filter(d -> !d.isNegative())
.orElse(forceExecuteExpiredJobs
.filter(Boolean.TRUE::equals)
.map(f -> Duration.ofSeconds(1))
.orElse(Duration.ofSeconds(-1)));
}
public PublisherBuilder handleJobExecutionSuccess(JobDetails futureJob) {
return ReactiveStreams.of(futureJob)
.map(job -> JobDetails.builder().of(job).incrementExecutionCounter().build())
.peek(job -> job.getTrigger().nextFireTime())
.flatMapCompletionStage(jobRepository::save)
//check if it is a repeatable job
.flatMap(job -> Optional
.ofNullable(job.getTrigger())
.filter(trigger -> Objects.nonNull(trigger.hasNextFireTime()))
.map(time -> doJobScheduling(job))
//in case the job should not be executed anymore (there is no nextFireTime)
.orElseGet(() -> ReactiveStreams.of(jobWithStatus(job, JobStatus.EXECUTED))))
//final state EXECUTED, removing the job, it is not kept on the repository
.filter(j -> JobStatus.EXECUTED.equals(j.getStatus()))
.flatMap(j -> ReactiveStreams.fromCompletionStage(cancel(CompletableFuture.completedFuture(j))));
}
@Override
public PublisherBuilder handleJobExecutionSuccess(JobExecutionResponse response) {
return ReactiveStreams.of(response)
.map(JobExecutionResponse::getJobId)
.flatMapCompletionStage(jobRepository::get)
.flatMap(this::handleJobExecutionSuccess);
}
private boolean isExpired(ZonedDateTime expirationTime, int retries) {
final Duration limit =
Duration.ofMillis(maxIntervalLimitToRetryMillis)
.minus(Duration.ofMillis(retries * backoffRetryMillis));
return calculateDelay(expirationTime).plus(limit).isNegative();
}
private PublisherBuilder handleExpirationTime(JobDetails scheduledJob) {
return ReactiveStreams.of(scheduledJob)
.map(JobDetails::getTrigger)
.map(Trigger::hasNextFireTime)
.map(DateUtil::fromDate)
.flatMapCompletionStage(time -> isExpired(time, scheduledJob.getRetries())
? handleExpiredJob(scheduledJob)
: CompletableFuture.completedFuture(scheduledJob));
}
/**
* Retries to schedule the job execution with a backoff time of {@link BaseTimerJobScheduler#backoffRetryMillis}
* between retries and a limit of max interval of {@link BaseTimerJobScheduler#maxIntervalLimitToRetryMillis}
* to retry, after this interval it the job it the job is not successfully executed it will remain in error
* state, with no more retries.
* @param errorResponse
* @return
*/
@Override
public PublisherBuilder handleJobExecutionError(JobExecutionResponse errorResponse) {
return handleRetry(jobRepository.get(errorResponse.getJobId()));
}
private PublisherBuilder handleRetry(CompletionStage futureJob) {
return ReactiveStreams.fromCompletionStage(futureJob)
.flatMap(scheduledJob -> handleExpirationTime(scheduledJob)
.map(JobDetails::getStatus)
.filter(s -> !JobStatus.ERROR.equals(s))
.map(s -> scheduleRegistering(scheduledJob, Optional.of(getRetryTrigger())))
.flatMap(p -> p)
.map(scheduleId -> JobDetails.builder()
.of(jobWithStatusAndHandle(scheduledJob, JobStatus.RETRY, scheduleId))
.incrementRetries()
.build())
.map(jobRepository::save)
.flatMapCompletionStage(p -> p))
.peek(job -> LOGGER.debug("Retry executed {}", job));
}
private PointInTimeTrigger getRetryTrigger() {
return new PointInTimeTrigger(DateUtil.now().plus(backoffRetryMillis,
ChronoUnit.MILLIS).toInstant().toEpochMilli(), null, null);
}
private CompletionStage handleExpiredJob(JobDetails scheduledJob) {
return Optional.of(jobWithStatus(scheduledJob, JobStatus.ERROR))
//final state, removing the job
.map(j -> jobRepository
.delete(j)
.thenApply(deleted -> {
unregisterScheduledJob(j);
LOGGER.warn("Retry limit exceeded for job{}", j);
return j;
}))
.orElse(null);
}
private PublisherBuilder scheduleRegistering(JobDetails job, Optional trigger) {
return doSchedule(job, trigger)
.peek(registerScheduledJob(job));
}
private Consumer registerScheduledJob(JobDetails job) {
return s -> schedulerControl.put(job.getId(), DateUtil.now());
}
public abstract PublisherBuilder doSchedule(JobDetails job, Optional trigger);
private ZonedDateTime unregisterScheduledJob(JobDetails job) {
return schedulerControl.remove(job.getId());
}
public CompletionStage cancel(CompletionStage futureJob) {
return ReactiveStreams
.fromCompletionStageNullable(futureJob)
.peek(job -> LOGGER.debug("Cancel Job Scheduling {}", job))
.flatMap(scheduledJob -> Optional.ofNullable(scheduledJob.getScheduledId())
.map(id -> ReactiveStreams
.fromPublisher(this.doCancel(scheduledJob))
.map(b -> scheduledJob))
.orElse(ReactiveStreams.of(scheduledJob)))
//final state, removing the job
.flatMapCompletionStage(jobRepository::delete)
.peek(this::unregisterScheduledJob)
.findFirst()
.run()
.thenApply(job -> job.orElse(null));
}
@Override
public CompletionStage cancel(String jobId) {
return cancel(jobRepository
.get(jobId)
.thenApply(scheduledJob -> Optional
.ofNullable(scheduledJob)
.map(j -> jobWithStatus(j, JobStatus.CANCELED))
.orElse(null)));
}
public abstract Publisher doCancel(JobDetails scheduledJob);
@Override
public Optional scheduled(String jobId) {
return Optional.ofNullable(schedulerControl.get(jobId));
}
public void setForceExecuteExpiredJobs(boolean forceExecuteExpiredJobs) {
this.forceExecuteExpiredJobs = Optional.of(forceExecuteExpiredJobs);
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy