From d0bcd989c4a246c887e14982c786403e44159202 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pere=20Fern=C3=A1ndez?= Date: Tue, 3 Sep 2024 15:07:03 +0200 Subject: [PATCH 1/4] incubator-kie-issues#458: Create JPA Storage for `Job Service` (#2094) * incubator-kie-issues#458: Create JPA Storage for `Job Service` * fix formatting --- jobs-service/jobs-service-storage-jpa/pom.xml | 122 ++++++++++ .../jpa/JPAReactiveJobRepository.java | 226 ++++++++++++++++++ ...eactiveJobServiceManagementRepository.java | 133 +++++++++++ .../jpa/converter/JsonBinaryConverter.java | 50 ++++ .../jpa/model/JobDetailsEntity.java | 188 +++++++++++++++ .../jpa/model/JobServiceManagementEntity.java | 66 +++++ .../JobDetailsEntityRepository.java | 30 +++ .../JobServiceManagementEntityRepository.java | 30 +++ .../jpa/utils/ReactiveRepositoryHelper.java | 51 ++++ .../src/main/resources/META-INF/beans.xml | 20 ++ .../db/jobs-service/V2.0.0__Create_Tables.sql | 46 ++++ .../jpa/JPAReactiveJobRepositoryTest.java | 48 ++++ ...iveJobServiceManagementRepositoryTest.java | 107 +++++++++ .../service/resource/JPAJobResourceTest.java | 29 +++ .../src/test/resources/application.properties | 32 +++ jobs-service/pom.xml | 1 + kogito-apps-bom/pom.xml | 11 + 17 files changed, 1190 insertions(+) create mode 100644 jobs-service/jobs-service-storage-jpa/pom.xml create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepository.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/converter/JsonBinaryConverter.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobDetailsEntity.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobServiceManagementEntity.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobDetailsEntityRepository.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobServiceManagementEntityRepository.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/utils/ReactiveRepositoryHelper.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/resources/META-INF/beans.xml create mode 100644 jobs-service/jobs-service-storage-jpa/src/main/resources/db/jobs-service/V2.0.0__Create_Tables.sql create mode 100644 jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepositoryTest.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/resource/JPAJobResourceTest.java create mode 100644 jobs-service/jobs-service-storage-jpa/src/test/resources/application.properties diff --git a/jobs-service/jobs-service-storage-jpa/pom.xml b/jobs-service/jobs-service-storage-jpa/pom.xml new file mode 100644 index 0000000000..e756309a0f --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/pom.xml @@ -0,0 +1,122 @@ + + + + 4.0.0 + + org.kie.kogito + jobs-service + 999-SNAPSHOT + + + jobs-service-storage-jpa + Kogito Apps :: Jobs Service :: Storage :: JPA + Jobs Service (Timers and Async Jobs) JPA Storage + + + org.kie.kogito.job.service.repository.jpa + + + + + org.kie.kogito + jobs-service-common + + + jakarta.persistence + jakarta.persistence-api + + + io.smallrye.reactive + mutiny-zero-flow-adapters + + + io.quarkus + quarkus-hibernate-orm-panache + + + io.quarkus + quarkus-jdbc-h2 + + + io.quarkus + quarkus-flyway + + + org.kie.kogito + jobs-service-common + test-jar + test + + + io.quarkus + quarkus-test-h2 + test + + + org.kie.kogito + kogito-quarkus-test-utils + test + + + io.quarkus + quarkus-junit5 + test + + + org.mockito + mockito-junit-jupiter + test + + + org.mockito + mockito-core + test + + + org.assertj + assertj-core + test + + + org.awaitility + awaitility + test + + + io.rest-assured + rest-assured + test + + + org.keycloak + keycloak-core + test + + + com.github.tomakehurst + wiremock-jre8 + test + + + \ No newline at end of file diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepository.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepository.java new file mode 100644 index 0000000000..55f17d21d0 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepository.java @@ -0,0 +1,226 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa; + +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.temporal.ChronoUnit; +import java.util.Arrays; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletionStage; +import java.util.function.Function; + +import org.eclipse.microprofile.reactive.streams.operators.PublisherBuilder; +import org.eclipse.microprofile.reactive.streams.operators.ReactiveStreams; +import org.kie.kogito.jackson.utils.ObjectMapperFactory; +import org.kie.kogito.jobs.service.model.JobDetails; +import org.kie.kogito.jobs.service.model.JobStatus; +import org.kie.kogito.jobs.service.repository.ReactiveJobRepository; +import org.kie.kogito.jobs.service.repository.impl.BaseReactiveJobRepository; +import org.kie.kogito.jobs.service.repository.jpa.model.JobDetailsEntity; +import org.kie.kogito.jobs.service.repository.jpa.repository.JobDetailsEntityRepository; +import org.kie.kogito.jobs.service.repository.jpa.utils.ReactiveRepositoryHelper; +import org.kie.kogito.jobs.service.repository.marshaller.RecipientMarshaller; +import org.kie.kogito.jobs.service.repository.marshaller.TriggerMarshaller; +import org.kie.kogito.jobs.service.stream.JobEventPublisher; +import org.kie.kogito.jobs.service.utils.DateUtil; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import io.quarkus.panache.common.Parameters; +import io.quarkus.panache.common.Sort; +import io.smallrye.mutiny.Multi; +import io.vertx.core.Vertx; +import io.vertx.core.json.JsonObject; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import static java.time.OffsetDateTime.now; +import static mutiny.zero.flow.adapters.AdaptersToReactiveStreams.publisher; +import static org.kie.kogito.jobs.service.utils.DateUtil.DEFAULT_ZONE; + +@ApplicationScoped +public class JPAReactiveJobRepository extends BaseReactiveJobRepository implements ReactiveJobRepository { + + private static final String JOBS_BETWEEN_FIRE_TIMES_QUERY = "select job " + + "from JobDetailsEntity job " + + "where job.fireTime between :from and :to and job.status in :status"; + + private final JobDetailsEntityRepository repository; + private final ReactiveRepositoryHelper reactiveRepositoryHelper; + + private final TriggerMarshaller triggerMarshaller; + private final RecipientMarshaller recipientMarshaller; + + JPAReactiveJobRepository() { + this(null, null, null, null, null, null); + } + + @Inject + public JPAReactiveJobRepository(Vertx vertx, JobEventPublisher jobEventPublisher, JobDetailsEntityRepository repository, + ReactiveRepositoryHelper reactiveRepositoryHelper, + TriggerMarshaller triggerMarshaller, RecipientMarshaller recipientMarshaller) { + super(vertx, jobEventPublisher); + this.repository = repository; + this.reactiveRepositoryHelper = reactiveRepositoryHelper; + this.triggerMarshaller = triggerMarshaller; + this.recipientMarshaller = recipientMarshaller; + } + + @Override + public CompletionStage doSave(JobDetails job) { + return this.reactiveRepositoryHelper.runAsync(() -> persist(job)) + .thenApply(this::from); + } + + private JobDetailsEntity persist(JobDetails job) { + JobDetailsEntity jobDetailsInstance = repository.findByIdOptional(job.getId()).orElseGet(JobDetailsEntity::new); + + merge(job, jobDetailsInstance); + + repository.persist(jobDetailsInstance); + + return repository.findById(job.getId()); + } + + @Override + public CompletionStage get(String id) { + return this.reactiveRepositoryHelper.runAsync(() -> repository.findById(id)) + .thenApply(this::from); + } + + @Override + public CompletionStage exists(String id) { + return this.reactiveRepositoryHelper.runAsync(() -> repository.findByIdOptional(id)) + .thenApply(Optional::isPresent); + } + + @Override + public CompletionStage delete(String id) { + return this.reactiveRepositoryHelper.runAsync(() -> this.deleteJob(id)) + .thenApply(this::from); + + } + + private JobDetailsEntity deleteJob(String id) { + JobDetailsEntity jobDetailsInstance = repository.findById(id); + + if (Objects.isNull(jobDetailsInstance)) { + return null; + } + + repository.delete(jobDetailsInstance); + + return jobDetailsInstance; + } + + String toColumName(SortTermField field) { + return switch (field) { + case FIRE_TIME -> "fireTime"; + case CREATED -> "created"; + case ID -> "id"; + default -> throw new IllegalArgumentException("No colum name is defined for field: " + field); + }; + } + + @Override + public PublisherBuilder findByStatusBetweenDates(ZonedDateTime fromFireTime, + ZonedDateTime toFireTime, + JobStatus[] status, + SortTerm[] orderBy) { + + Parameters params = Parameters.with("from", fromFireTime.toOffsetDateTime()) + .and("to", toFireTime.toOffsetDateTime()) + .and("status", Arrays.stream(status).map(Enum::toString).toList()); + + Sort sort = Sort.empty(); + + Arrays.stream(orderBy).forEach(sortTerm -> { + String columnName = toColumName(sortTerm.getField()); + sort.and(columnName, sortTerm.isAsc() ? Sort.Direction.Ascending : Sort.Direction.Descending); + }); + + return ReactiveStreams.fromPublisher(publisher(Multi.createFrom() + .completionStage(this.reactiveRepositoryHelper.runAsync(() -> repository.list(JOBS_BETWEEN_FIRE_TIMES_QUERY, sort, params.map()))) + .flatMap(jobDetailsEntities -> Multi.createFrom().iterable(jobDetailsEntities)) + .map(this::from))); + + } + + JobDetailsEntity merge(JobDetails job, JobDetailsEntity instance) { + if (Objects.isNull(instance)) { + instance = new JobDetailsEntity(); + } + + ObjectMapper mapper = ObjectMapperFactory.get(); + + OffsetDateTime lastUpdate = now().truncatedTo(ChronoUnit.MILLIS); + + instance.setId(job.getId()); + instance.setCorrelationId(job.getCorrelationId()); + instance.setStatus(mapOptionalValue(job.getStatus(), Enum::name)); + instance.setLastUpdate(lastUpdate); + instance.setRetries(job.getRetries()); + instance.setExecutionCounter(job.getExecutionCounter()); + instance.setScheduledId(job.getScheduledId()); + instance.setPriority(job.getPriority()); + + instance.setRecipient(mapOptionalValue(job.getRecipient(), recipient -> mapper.valueToTree(recipientMarshaller.marshall(recipient).getMap()))); + instance.setTrigger(mapOptionalValue(job.getTrigger(), trigger -> mapper.valueToTree(triggerMarshaller.marshall(job.getTrigger()).getMap()))); + instance.setFireTime(mapOptionalValue(job.getTrigger().hasNextFireTime(), DateUtil::dateToOffsetDateTime)); + + instance.setExecutionTimeout(job.getExecutionTimeout()); + instance.setExecutionTimeoutUnit(mapOptionalValue(job.getExecutionTimeoutUnit(), Enum::name)); + + instance.setCreated(Optional.ofNullable(job.getCreated()).map(ZonedDateTime::toOffsetDateTime).orElse(lastUpdate)); + + return instance; + } + + JobDetails from(JobDetailsEntity instance) { + if (instance == null) { + return null; + } + + return JobDetails.builder() + .id(instance.getId()) + .correlationId(instance.getCorrelationId()) + .status(mapOptionalValue(instance.getStatus(), JobStatus::valueOf)) + .lastUpdate(instance.getLastUpdate().atZoneSameInstant(DEFAULT_ZONE)) + .retries(instance.getRetries()) + .executionCounter(instance.getExecutionCounter()) + .scheduledId(instance.getScheduledId()) + .priority(instance.getPriority()) + .recipient(mapOptionalValue(instance.getRecipient(), recipient -> recipientMarshaller.unmarshall(JsonObject.mapFrom(recipient)))) + .trigger(mapOptionalValue(instance.getTrigger(), trigger -> triggerMarshaller.unmarshall(JsonObject.mapFrom(trigger)))) + .executionTimeout(instance.getExecutionTimeout()) + .executionTimeoutUnit(mapOptionalValue(instance.getExecutionTimeoutUnit(), ChronoUnit::valueOf)) + .created(instance.getCreated().atZoneSameInstant(DEFAULT_ZONE)) + .build(); + } + + private R mapOptionalValue(T object, Function mapper) { + return Optional.ofNullable(object) + .map(mapper) + .orElse(null); + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java new file mode 100644 index 0000000000..0634dc8442 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa; + +import java.util.Objects; +import java.util.function.Function; + +import org.kie.kogito.jobs.service.model.JobServiceManagementInfo; +import org.kie.kogito.jobs.service.repository.JobServiceManagementRepository; +import org.kie.kogito.jobs.service.repository.jpa.model.JobServiceManagementEntity; +import org.kie.kogito.jobs.service.repository.jpa.repository.JobServiceManagementEntityRepository; +import org.kie.kogito.jobs.service.repository.jpa.utils.ReactiveRepositoryHelper; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import io.quarkus.panache.common.Parameters; +import io.smallrye.mutiny.Uni; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +import static java.time.OffsetDateTime.now; + +@ApplicationScoped +public class JPAReactiveJobServiceManagementRepository implements JobServiceManagementRepository { + + private static final Logger LOGGER = LoggerFactory.getLogger(JPAReactiveJobServiceManagementRepository.class); + + private final JobServiceManagementEntityRepository repository; + private final ReactiveRepositoryHelper reactiveRepositoryHelper; + + @Inject + public JPAReactiveJobServiceManagementRepository(JobServiceManagementEntityRepository repository, + ReactiveRepositoryHelper reactiveRepositoryHelper) { + this.repository = repository; + this.reactiveRepositoryHelper = reactiveRepositoryHelper; + } + + @Override + public Uni getAndUpdate(String id, Function computeUpdate) { + LOGGER.info("get {}", id); + return Uni.createFrom() + .completionStage(this.reactiveRepositoryHelper.runAsync(() -> doGetAndUpdate(id, computeUpdate))) + .onItem().ifNotNull().invoke(info -> LOGGER.trace("got {}", info)); + } + + private JobServiceManagementInfo doGetAndUpdate(String id, Function computeUpdate) { + + JobServiceManagementInfo info = this.repository.findByIdOptional(id) + .map(this::from) + .orElse(null); + + return this.update(computeUpdate.apply(info)); + } + + @Override + public Uni set(JobServiceManagementInfo info) { + LOGGER.info("set {}", info); + return Uni.createFrom().completionStage(this.reactiveRepositoryHelper.runAsync(() -> this.doSet(info))); + } + + public JobServiceManagementInfo doSet(JobServiceManagementInfo info) { + return this.update(info); + } + + private JobServiceManagementInfo update(JobServiceManagementInfo info) { + + if (Objects.isNull(info)) { + return null; + } + + JobServiceManagementEntity jobService = this.repository.findByIdOptional(info.getId()).orElse(new JobServiceManagementEntity()); + + jobService.setId(info.getId()); + jobService.setToken(info.getToken()); + jobService.setLastHeartBeat(info.getLastHeartbeat()); + + repository.persist(jobService); + + return from(jobService); + } + + @Override + public Uni heartbeat(JobServiceManagementInfo info) { + return Uni.createFrom().completionStage(this.reactiveRepositoryHelper.runAsync(() -> this.doHeartbeat(info))); + } + + private JobServiceManagementEntity findById(String id) { + return repository.findById(id); + } + + private JobServiceManagementEntity findByIdAndToken(JobServiceManagementInfo info) { + return repository.find("#JobServiceManagementEntity.GetServiceByIdAndToken", Parameters.with("id", info.getId()).and("token", info.getToken()).map()) + .firstResultOptional().orElse(null); + } + + private JobServiceManagementInfo doHeartbeat(JobServiceManagementInfo info) { + JobServiceManagementEntity jobService = findByIdAndToken(info); + + if (jobService == null) { + return null; + } + + jobService.setLastHeartBeat(now()); + repository.persist(jobService); + + return from(jobService); + } + + JobServiceManagementInfo from(JobServiceManagementEntity jobService) { + if (Objects.isNull(jobService)) { + return null; + } + return new JobServiceManagementInfo(jobService.getId(), jobService.getToken(), jobService.getLastHeartBeat()); + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/converter/JsonBinaryConverter.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/converter/JsonBinaryConverter.java new file mode 100644 index 0000000000..b269ab9eaf --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/converter/JsonBinaryConverter.java @@ -0,0 +1,50 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa.converter; + +import java.io.IOException; +import java.io.UncheckedIOException; + +import org.kie.kogito.jackson.utils.ObjectMapperFactory; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import jakarta.persistence.AttributeConverter; + +public class JsonBinaryConverter implements AttributeConverter { + + @Override + public byte[] convertToDatabaseColumn(ObjectNode attribute) { + try { + return attribute == null ? null : ObjectMapperFactory.get().writeValueAsBytes(attribute); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + + @Override + public ObjectNode convertToEntityAttribute(byte[] dbData) { + try { + return dbData == null ? null : ObjectMapperFactory.get().readValue(dbData, ObjectNode.class); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } + +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobDetailsEntity.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobDetailsEntity.java new file mode 100644 index 0000000000..9c1b317b47 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobDetailsEntity.java @@ -0,0 +1,188 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa.model; + +import java.time.OffsetDateTime; + +import org.kie.kogito.jobs.service.repository.jpa.converter.JsonBinaryConverter; + +import com.fasterxml.jackson.databind.node.ObjectNode; + +import jakarta.persistence.*; + +@Entity +@Table(name = "job_details", + indexes = { + @Index(name = "job_details_fire_time_idx", columnList = "fire_time"), + @Index(name = "job_details_created_idx", columnList = "created") + }) +public class JobDetailsEntity { + + @Id + private String id; + + @Column(name = "correlation_id") + private String correlationId; + + private String status; + + @Column(name = "last_update") + @Temporal(TemporalType.TIMESTAMP) + private OffsetDateTime lastUpdate; + + private Integer retries; + + @Column(name = "execution_counter") + private Integer executionCounter; + + @Column(name = "scheduled_id") + private String scheduledId; + + private Integer priority; + + @Convert(converter = JsonBinaryConverter.class) + private ObjectNode recipient; + + @Convert(converter = JsonBinaryConverter.class) + private ObjectNode trigger; + + @Column(name = "fire_time") + @Temporal(TemporalType.TIMESTAMP) + private OffsetDateTime fireTime; + + @Column(name = "execution_timeout") + private Long executionTimeout; + @Column(name = "execution_timeout_unit") + private String executionTimeoutUnit; + + @Temporal(TemporalType.TIMESTAMP) + private OffsetDateTime created; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getCorrelationId() { + return correlationId; + } + + public void setCorrelationId(String correlationId) { + this.correlationId = correlationId; + } + + public String getStatus() { + return status; + } + + public void setStatus(String status) { + this.status = status; + } + + public OffsetDateTime getLastUpdate() { + return lastUpdate; + } + + public void setLastUpdate(OffsetDateTime lastUpdate) { + this.lastUpdate = lastUpdate; + } + + public Integer getRetries() { + return retries; + } + + public void setRetries(Integer retries) { + this.retries = retries; + } + + public Integer getExecutionCounter() { + return executionCounter; + } + + public void setExecutionCounter(Integer executionCounter) { + this.executionCounter = executionCounter; + } + + public String getScheduledId() { + return scheduledId; + } + + public void setScheduledId(String scheduledId) { + this.scheduledId = scheduledId; + } + + public Integer getPriority() { + return priority; + } + + public void setPriority(Integer priority) { + this.priority = priority; + } + + public ObjectNode getRecipient() { + return recipient; + } + + public void setRecipient(ObjectNode recipient) { + this.recipient = recipient; + } + + public ObjectNode getTrigger() { + return trigger; + } + + public void setTrigger(ObjectNode trigger) { + this.trigger = trigger; + } + + public OffsetDateTime getFireTime() { + return fireTime; + } + + public void setFireTime(OffsetDateTime fireTime) { + this.fireTime = fireTime; + } + + public Long getExecutionTimeout() { + return executionTimeout; + } + + public void setExecutionTimeout(Long executionTimeout) { + this.executionTimeout = executionTimeout; + } + + public String getExecutionTimeoutUnit() { + return executionTimeoutUnit; + } + + public void setExecutionTimeoutUnit(String executionTimeoutUnit) { + this.executionTimeoutUnit = executionTimeoutUnit; + } + + public OffsetDateTime getCreated() { + return created; + } + + public void setCreated(OffsetDateTime created) { + this.created = created; + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobServiceManagementEntity.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobServiceManagementEntity.java new file mode 100644 index 0000000000..77ac0a67ac --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/model/JobServiceManagementEntity.java @@ -0,0 +1,66 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa.model; + +import java.time.OffsetDateTime; + +import jakarta.persistence.*; + +@Entity +@NamedQuery(name = "JobServiceManagementEntity.GetServiceByIdAndToken", + query = "select service " + + "from JobServiceManagementEntity service " + + "where service.id = :id and service.token = :token") +@Table(name = "job_service_management") +public class JobServiceManagementEntity { + + @Id + private String id; + + @Column(name = "last_heartbeat") + @Temporal(TemporalType.TIMESTAMP) + private OffsetDateTime lastHeartBeat; + + private String token; + + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public OffsetDateTime getLastHeartBeat() { + return lastHeartBeat; + } + + public void setLastHeartBeat(OffsetDateTime lastHeartBeat) { + this.lastHeartBeat = lastHeartBeat; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobDetailsEntityRepository.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobDetailsEntityRepository.java new file mode 100644 index 0000000000..dd1003f635 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobDetailsEntityRepository.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa.repository; + +import org.kie.kogito.jobs.service.repository.jpa.model.JobDetailsEntity; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class JobDetailsEntityRepository implements PanacheRepositoryBase { +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobServiceManagementEntityRepository.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobServiceManagementEntityRepository.java new file mode 100644 index 0000000000..1a528ccb27 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/repository/JobServiceManagementEntityRepository.java @@ -0,0 +1,30 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa.repository; + +import org.kie.kogito.jobs.service.repository.jpa.model.JobServiceManagementEntity; + +import io.quarkus.hibernate.orm.panache.PanacheRepositoryBase; + +import jakarta.enterprise.context.ApplicationScoped; + +@ApplicationScoped +public class JobServiceManagementEntityRepository implements PanacheRepositoryBase { +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/utils/ReactiveRepositoryHelper.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/utils/ReactiveRepositoryHelper.java new file mode 100644 index 0000000000..9dab31a5b2 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/utils/ReactiveRepositoryHelper.java @@ -0,0 +1,51 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa.utils; + +import java.util.concurrent.CompletionStage; +import java.util.function.Supplier; + +import io.quarkus.narayana.jta.QuarkusTransaction; +import io.quarkus.narayana.jta.TransactionRunnerOptions; +import io.vertx.core.Vertx; + +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.inject.Inject; + +@ApplicationScoped +public class ReactiveRepositoryHelper { + + private Vertx vertx; + + @Inject + public ReactiveRepositoryHelper(Vertx vertx) { + this.vertx = vertx; + } + + public CompletionStage runAsync(Supplier blockingFunction) { + return vertx.executeBlocking(() -> wrapInTransaction(blockingFunction)).toCompletionStage(); + } + + private T wrapInTransaction(Supplier function) { + TransactionRunnerOptions runner = QuarkusTransaction.isActive() ? QuarkusTransaction.joiningExisting() : QuarkusTransaction.requiringNew(); + + return runner.call(function::get); + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/main/resources/META-INF/beans.xml b/jobs-service/jobs-service-storage-jpa/src/main/resources/META-INF/beans.xml new file mode 100644 index 0000000000..a0eb9fbf8c --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/resources/META-INF/beans.xml @@ -0,0 +1,20 @@ + diff --git a/jobs-service/jobs-service-storage-jpa/src/main/resources/db/jobs-service/V2.0.0__Create_Tables.sql b/jobs-service/jobs-service-storage-jpa/src/main/resources/db/jobs-service/V2.0.0__Create_Tables.sql new file mode 100644 index 0000000000..b48a32a95c --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/main/resources/db/jobs-service/V2.0.0__Create_Tables.sql @@ -0,0 +1,46 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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. + */ + +create table job_details +( + id varchar(50) primary key, + correlation_id varchar(50), + status varchar(40), + last_update timestamp, + retries integer, + execution_counter integer, + scheduled_id varchar(40), + priority integer, + recipient varbinary(max), + trigger varbinary(max), + fire_time timestamp, + execution_timeout bigint, + execution_timeout_unit varchar(40), + created timestamp +); + +create index job_details_fire_time_idx on job_details (fire_time); +create index job_details_created_idx on job_details (created); + +CREATE TABLE job_service_management +( + id varchar(40) primary key, + last_heartbeat timestamp, + token varchar(40) unique +); \ No newline at end of file diff --git a/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepositoryTest.java b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepositoryTest.java new file mode 100644 index 0000000000..d1fe3cafee --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobRepositoryTest.java @@ -0,0 +1,48 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa; + +import org.junit.jupiter.api.BeforeEach; +import org.kie.kogito.jobs.service.repository.ReactiveJobRepository; +import org.kie.kogito.jobs.service.repository.impl.BaseJobRepositoryTest; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.h2.H2DatabaseTestResource; +import io.quarkus.test.junit.QuarkusTest; + +import jakarta.inject.Inject; + +@QuarkusTest +@QuarkusTestResource(H2DatabaseTestResource.class) +public class JPAReactiveJobRepositoryTest extends BaseJobRepositoryTest { + + @Inject + JPAReactiveJobRepository tested; + + @BeforeEach + public void setUp() throws Exception { + + super.setUp(); + } + + @Override + public ReactiveJobRepository tested() { + return tested; + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java new file mode 100644 index 0000000000..a0a18cab45 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java @@ -0,0 +1,107 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.repository.jpa; + +import java.time.OffsetDateTime; +import java.util.concurrent.atomic.AtomicReference; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.kie.kogito.jobs.service.model.JobServiceManagementInfo; +import org.kie.kogito.jobs.service.repository.JobServiceManagementRepository; +import org.kie.kogito.jobs.service.utils.DateUtil; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.h2.H2DatabaseTestResource; +import io.quarkus.test.junit.QuarkusTest; + +import jakarta.inject.Inject; + +import static org.assertj.core.api.Assertions.assertThat; + +@QuarkusTest +@QuarkusTestResource(H2DatabaseTestResource.class) +class JPAReactiveJobServiceManagementRepositoryTest { + + @Inject + JobServiceManagementRepository tested; + + @BeforeEach + void setUp() { + } + + @Test + void testGetAndUpdate() { + String id = "instance-id-1"; + String token = "token1"; + create(id, token); + + AtomicReference date = new AtomicReference<>(); + JobServiceManagementInfo updated = tested.getAndUpdate(id, info -> { + date.set(DateUtil.now().toOffsetDateTime()); + info.setLastHeartbeat(date.get()); + return info; + }).await().indefinitely(); + assertThat(updated.getId()).isEqualTo(id); + assertThat(date.get()).isNotNull(); + assertThat(updated.getLastHeartbeat()).isEqualTo(date.get()); + assertThat(updated.getToken()).isEqualTo(token); + } + + @Test + void testGetAndUpdateNotExisting() { + String id = "instance-id-2"; + AtomicReference found = new AtomicReference<>(new JobServiceManagementInfo()); + JobServiceManagementInfo updated = tested.getAndUpdate(id, info -> { + found.set(info); + return info; + }).await().indefinitely(); + assertThat(updated).isNull(); + assertThat(found.get()).isNull(); + } + + private JobServiceManagementInfo create(String id, String token) { + JobServiceManagementInfo created = tested.set(new JobServiceManagementInfo(id, token, null)).await().indefinitely(); + assertThat(created.getId()).isEqualTo(id); + assertThat(created.getToken()).isEqualTo(token); + assertThat(created.getLastHeartbeat()).isNull(); + return created; + } + + @Test + void testHeartbeat() { + String id = "instance-id-3"; + String token = "token3"; + JobServiceManagementInfo created = create(id, token); + + JobServiceManagementInfo updated = tested.heartbeat(created).await().indefinitely(); + assertThat(updated.getLastHeartbeat()).isNotNull(); + assertThat(updated.getLastHeartbeat()).isBefore(DateUtil.now().plusSeconds(1).toOffsetDateTime()); + } + + @Test + void testConflictHeartbeat() { + String id = "instance-id-4"; + String token = "token4"; + create(id, token); + + JobServiceManagementInfo updated = tested.heartbeat(new JobServiceManagementInfo(id, "differentToken", null)).await().indefinitely(); + assertThat(updated).isNull(); + } +} diff --git a/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/resource/JPAJobResourceTest.java b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/resource/JPAJobResourceTest.java new file mode 100644 index 0000000000..0fdc67d712 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/resource/JPAJobResourceTest.java @@ -0,0 +1,29 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.resource; + +import io.quarkus.test.common.QuarkusTestResource; +import io.quarkus.test.h2.H2DatabaseTestResource; +import io.quarkus.test.junit.QuarkusTest; + +@QuarkusTest +@QuarkusTestResource(H2DatabaseTestResource.class) +public class JPAJobResourceTest extends BaseJobResourceTest { + +} diff --git a/jobs-service/jobs-service-storage-jpa/src/test/resources/application.properties b/jobs-service/jobs-service-storage-jpa/src/test/resources/application.properties new file mode 100644 index 0000000000..ef10d96215 --- /dev/null +++ b/jobs-service/jobs-service-storage-jpa/src/test/resources/application.properties @@ -0,0 +1,32 @@ +# +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you 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. +# +# Kogito +kogito.apps.persistence.type=jdbc +# Data source +quarkus.datasource.db-kind=h2 +quarkus.datasource.username=kogito +quarkus.datasource.jdbc.url=jdbc:h2:mem:default;NON_KEYWORDS=VALUE,KEY +quarkus.flyway.migrate-at-start=true +quarkus.flyway.clean-at-start=true +quarkus.flyway.locations=db/jobs-service +# Disabling Security for tests +quarkus.oidc.enabled=false +quarkus.oidc.tenant-enabled=false +quarkus.oidc.auth-server-url=none +quarkus.keycloak.devservices.enabled=false \ No newline at end of file diff --git a/jobs-service/pom.xml b/jobs-service/pom.xml index df5f21f771..8eb5f0ff2a 100644 --- a/jobs-service/pom.xml +++ b/jobs-service/pom.xml @@ -39,6 +39,7 @@ jobs-recipients jobs-service-common jobs-service-postgresql-common + jobs-service-storage-jpa jobs-service-postgresql jobs-service-inmemory kogito-addons-jobs-service diff --git a/kogito-apps-bom/pom.xml b/kogito-apps-bom/pom.xml index 5e903d4972..774cd46967 100644 --- a/kogito-apps-bom/pom.xml +++ b/kogito-apps-bom/pom.xml @@ -78,6 +78,17 @@ ${project.version} sources + + org.kie.kogito + jobs-service-storage-jpa + ${project.version} + + + org.kie.kogito + jobs-service-storage-jpa + ${project.version} + sources + org.kie.kogito jobs-service-postgresql From b2f3dff7ba200849fb936b75b1732effedf8ecae Mon Sep 17 00:00:00 2001 From: Walter Medvedeo Date: Wed, 4 Sep 2024 14:27:57 +0200 Subject: [PATCH 2/4] kie-kogito-apps-2069: Improve Jobs Service liveness check to limit the amount of time to get the leader status (#2096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * kie-kogito-apps-2069: Improve Jobs Service liveness check to limit the amount of time to get the leader status * Update jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheckTest.java Co-authored-by: Gonzalo Muñoz * Apply formatting * Tests improvements * JPAReactiveJobServiceManagementRepository updates --------- Co-authored-by: Gonzalo Muñoz --- .../management/JobServiceInstanceManager.java | 2 +- .../JobServiceLeaderLivenessHealthCheck.java | 76 ++++++++++++++++++ .../JobServiceManagementRepository.java | 2 + ...DefaultJobServiceManagementRepository.java | 6 ++ .../META-INF/microprofile-config.properties | 2 + .../JobServiceInstanceManagerTest.java | 20 ++++- ...bServiceLeaderLivenessHealthCheckTest.java | 77 +++++++++++++++++++ .../impl/VertxTimerServiceSchedulerTest.java | 15 ++-- ...tgreSqlJobServiceManagementRepository.java | 10 +++ ...eactiveJobServiceManagementRepository.java | 16 ++++ ...iveJobServiceManagementRepositoryTest.java | 20 +++++ 11 files changed, 236 insertions(+), 10 deletions(-) create mode 100644 jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheck.java create mode 100644 jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheckTest.java diff --git a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManager.java b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManager.java index fea4350a02..3e986ba48d 100644 --- a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManager.java +++ b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManager.java @@ -174,7 +174,7 @@ protected Uni tryBecomeLeader(JobServiceManagementInfo protected Uni release(JobServiceManagementInfo info) { leader.set(false); - return repository.set(new JobServiceManagementInfo(info.getId(), null, null)) + return repository.release(info) .onItem().invoke(this::disableCommunication) .onItem().invoke(i -> LOGGER.info("Leader instance released")) .onFailure().invoke(ex -> LOGGER.error("Error releasing leader")) diff --git a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheck.java b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheck.java new file mode 100644 index 0000000000..30c9998726 --- /dev/null +++ b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheck.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.management; + +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicLong; + +import org.eclipse.microprofile.config.inject.ConfigProperty; +import org.eclipse.microprofile.health.HealthCheck; +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.eclipse.microprofile.health.HealthCheckResponseBuilder; +import org.eclipse.microprofile.health.Liveness; + +import jakarta.annotation.PostConstruct; +import jakarta.enterprise.context.ApplicationScoped; +import jakarta.enterprise.event.Observes; + +@Liveness +@ApplicationScoped +public class JobServiceLeaderLivenessHealthCheck implements HealthCheck { + + private final AtomicBoolean enabled = new AtomicBoolean(false); + + private final AtomicLong startTime = new AtomicLong(); + + private static final String EXPIRATION_IN_SECONDS = "kogito.jobs-service.management.leader-check.expiration-in-seconds"; + + @ConfigProperty(name = EXPIRATION_IN_SECONDS, defaultValue = "-1") + long expirationInSeconds; + + @PostConstruct + void init() { + startTime.set(getCurrentTimeMillis()); + } + + @Override + public HealthCheckResponse call() { + final HealthCheckResponseBuilder responseBuilder = HealthCheckResponse.named("Get Leader Instance Timeout"); + if (hasExpired() && !enabled.get()) { + return responseBuilder.down().build(); + } + return responseBuilder.up().build(); + } + + boolean hasExpired() { + return (expirationInSeconds > 0) && (getCurrentTimeMillis() - startTime.get()) > (expirationInSeconds * 1000); + } + + protected void onMessagingStatusChange(@Observes MessagingChangeEvent event) { + this.enabled.set(event.isEnabled()); + startTime.set(getCurrentTimeMillis()); + } + + /** + * Facilitates testing + */ + long getCurrentTimeMillis() { + return System.currentTimeMillis(); + } +} diff --git a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/JobServiceManagementRepository.java b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/JobServiceManagementRepository.java index 405c6de6e5..6b17c370e7 100644 --- a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/JobServiceManagementRepository.java +++ b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/JobServiceManagementRepository.java @@ -30,6 +30,8 @@ public interface JobServiceManagementRepository { Uni set(JobServiceManagementInfo info); + Uni release(JobServiceManagementInfo info); + Uni heartbeat(JobServiceManagementInfo info); } diff --git a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/impl/DefaultJobServiceManagementRepository.java b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/impl/DefaultJobServiceManagementRepository.java index 7a7124127c..8d377e0f9c 100644 --- a/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/impl/DefaultJobServiceManagementRepository.java +++ b/jobs-service/jobs-service-common/src/main/java/org/kie/kogito/jobs/service/repository/impl/DefaultJobServiceManagementRepository.java @@ -52,4 +52,10 @@ public Uni heartbeat(JobServiceManagementInfo info) { info.setLastHeartbeat(DateUtil.now().toOffsetDateTime()); return set(info); } + + @Override + public Uni release(JobServiceManagementInfo info) { + instance.set(new JobServiceManagementInfo(info.getId(), null, null)); + return Uni.createFrom().item(true); + } } diff --git a/jobs-service/jobs-service-common/src/main/resources/META-INF/microprofile-config.properties b/jobs-service/jobs-service-common/src/main/resources/META-INF/microprofile-config.properties index a24e7828c7..c958aa2a12 100644 --- a/jobs-service/jobs-service-common/src/main/resources/META-INF/microprofile-config.properties +++ b/jobs-service/jobs-service-common/src/main/resources/META-INF/microprofile-config.properties @@ -39,6 +39,8 @@ quarkus.http.port=8080 mp.openapi.filter=org.kie.kogito.jobs.service.openapi.JobServiceModelFilter # Job Service +quarkus.smallrye-health.check."org.kie.kogito.jobs.service.management.JobServiceLeaderLivenessHealthCheck".enabled=false + kogito.jobs-service.maxIntervalLimitToRetryMillis=60000 kogito.jobs-service.backoffRetryMillis=1000 kogito.jobs-service.schedulerChunkInMinutes=10 diff --git a/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManagerTest.java b/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManagerTest.java index 5fada45266..c099717b4f 100644 --- a/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManagerTest.java +++ b/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceInstanceManagerTest.java @@ -20,6 +20,7 @@ import java.time.OffsetDateTime; import java.util.Arrays; +import java.util.concurrent.TimeUnit; import java.util.function.Function; import java.util.stream.Stream; @@ -46,8 +47,10 @@ import jakarta.enterprise.inject.Instance; import static org.assertj.core.api.Assertions.assertThat; +import static org.awaitility.Awaitility.await; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.lenient; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.never; @@ -112,7 +115,7 @@ void onShutdown() { verify(tested, times(1)).release(infoCaptor.capture()); assertThat(infoCaptor.getValue()).isEqualTo(tested.getCurrentInfo()); - verify(repository, times(1)).set(new JobServiceManagementInfo()); + verify(repository, times(1)).release(tested.getCurrentInfo()); } @Test @@ -153,7 +156,18 @@ void heartbeatNotLeader() { @Test void heartbeatLeader() { tested.startup(startupEvent); - tested.heartbeat(tested.getCurrentInfo()).await().indefinitely(); - verify(repository).heartbeat(tested.getCurrentInfo()); + await().atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> { + assertThat(tested.isLeader()).isTrue(); + }); + await().atMost(30, TimeUnit.SECONDS) + .untilAsserted(() -> { + verify(repository, atLeastOnce()).heartbeat(infoCaptor.capture()); + }); + JobServiceManagementInfo lastHeartbeat = infoCaptor.getValue(); + assertThat(lastHeartbeat).isNotNull(); + assertThat(lastHeartbeat.getId()).isEqualTo(tested.getCurrentInfo().getId()); + assertThat(lastHeartbeat.getToken()).isEqualTo(tested.getCurrentInfo().getToken()); + assertThat(lastHeartbeat.getLastHeartbeat()).isNotNull(); } } diff --git a/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheckTest.java b/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheckTest.java new file mode 100644 index 0000000000..9d19a8897d --- /dev/null +++ b/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/management/JobServiceLeaderLivenessHealthCheckTest.java @@ -0,0 +1,77 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you 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.management; + +import org.eclipse.microprofile.health.HealthCheckResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.spy; + +class JobServiceLeaderLivenessHealthCheckTest { + + private static final long START_TIME = 1234; + + private JobServiceLeaderLivenessHealthCheck healthCheck; + + @BeforeEach + void setUp() { + healthCheck = spy(new JobServiceLeaderLivenessHealthCheck()); + doReturn(START_TIME).when(healthCheck).getCurrentTimeMillis(); + healthCheck.init(); + } + + @Test + void timeoutNotSet() { + doReturn(START_TIME + 1000 * 50).when(healthCheck).getCurrentTimeMillis(); + assertThat(healthCheck.call().getStatus()) + .isNotNull() + .isEqualTo(HealthCheckResponse.Status.UP); + } + + @Test + void timeoutSetButNotReached() { + healthCheck.expirationInSeconds = 60; + doReturn(START_TIME + 1000 * 10).when(healthCheck).getCurrentTimeMillis(); + assertThat(healthCheck.call().getStatus()) + .isNotNull() + .isEqualTo(HealthCheckResponse.Status.UP); + } + + @Test + void timeoutSetAndReached() { + healthCheck.expirationInSeconds = 60; + doReturn(START_TIME + 1000 * 60 + 1).when(healthCheck).getCurrentTimeMillis(); + assertThat(healthCheck.call().getStatus()) + .isNotNull() + .isEqualTo(HealthCheckResponse.Status.DOWN); + } + + @Test + void statusChanged() { + healthCheck.onMessagingStatusChange(new MessagingChangeEvent(true)); + doReturn(START_TIME + 1000 * 10).when(healthCheck).getCurrentTimeMillis(); + HealthCheckResponse response = healthCheck.call(); + assertThat(response.getStatus()) + .isNotNull() + .isEqualTo(HealthCheckResponse.Status.UP); + } +} diff --git a/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/scheduler/impl/VertxTimerServiceSchedulerTest.java b/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/scheduler/impl/VertxTimerServiceSchedulerTest.java index b6c1421977..a48199817c 100644 --- a/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/scheduler/impl/VertxTimerServiceSchedulerTest.java +++ b/jobs-service/jobs-service-common/src/test/java/org/kie/kogito/jobs/service/scheduler/impl/VertxTimerServiceSchedulerTest.java @@ -62,7 +62,6 @@ class VertxTimerServiceSchedulerTest { private Job job; private JobContext context; private Trigger trigger; - private JobDetails jobDetails; @Mock private JobExecutorResolver jobExecutorResolver; @@ -89,19 +88,23 @@ public void setUp() { @Test void testScheduleJob() { - ZonedDateTime time = DateUtil.now().plusSeconds(1); - final ManageableJobHandle handle = schedule(time); + JobDetails jobDetails = JobDetails.builder().build(); doReturn(jobExecutor).when(jobExecutorResolver).get(any()); JobExecutionResponse response = new JobExecutionResponse(); Uni result = Uni.createFrom().item(response); PublisherBuilder executionSuccessPublisherBuilder = ReactiveStreams.of(jobDetails); doReturn(executionSuccessPublisherBuilder).when(reactiveJobScheduler).handleJobExecutionSuccess(response); doReturn(result).when(jobExecutor).execute(jobDetails); + ZonedDateTime time = DateUtil.now().plusSeconds(1); + final ManageableJobHandle handle = schedule(jobDetails, time); verify(vertx).setTimer(timeCaptor.capture(), any()); assertThat(timeCaptor.getValue()).isGreaterThanOrEqualTo(time.toInstant().minusMillis(System.currentTimeMillis()).toEpochMilli()); given().await() .atMost(2, TimeUnit.SECONDS) .untilAsserted(() -> verify(jobExecutorResolver).get(jobCaptor.capture())); + given().await() + .atMost(2, TimeUnit.SECONDS) + .untilAsserted(() -> verify(reactiveJobScheduler).handleJobExecutionSuccess(response)); assertThat(jobCaptor.getValue()).isEqualTo(jobDetails); assertThat(handle.isCancel()).isFalse(); assertThat(handle.getScheduledTime()).isNotNull(); @@ -109,7 +112,8 @@ void testScheduleJob() { @Test void testRemoveScheduleJob() { - final ManageableJobHandle handle = schedule(DateUtil.now().plusHours(1)); + JobDetails jobDetails = JobDetails.builder().build(); + final ManageableJobHandle handle = schedule(jobDetails, DateUtil.now().plusHours(1)); verify(vertx).setTimer(timeCaptor.capture(), any()); given().await() .atMost(1, TimeUnit.SECONDS) @@ -120,10 +124,9 @@ void testRemoveScheduleJob() { assertThat(tested.removeJob(handle)).isTrue(); } - private ManageableJobHandle schedule(ZonedDateTime time) { + private ManageableJobHandle schedule(JobDetails jobDetails, ZonedDateTime time) { final long timestamp = time.toInstant().toEpochMilli(); trigger = new PointInTimeTrigger(timestamp, null, null); - jobDetails = JobDetails.builder().build(); context = new JobDetailsContext(jobDetails); job = new DelegateJob(jobExecutorResolver, reactiveJobScheduler); return tested.scheduleJob(job, context, trigger); diff --git a/jobs-service/jobs-service-postgresql-common/src/main/java/org/kie/kogito/jobs/service/repository/postgresql/PostgreSqlJobServiceManagementRepository.java b/jobs-service/jobs-service-postgresql-common/src/main/java/org/kie/kogito/jobs/service/repository/postgresql/PostgreSqlJobServiceManagementRepository.java index 92f4f68e9e..f5141e0236 100644 --- a/jobs-service/jobs-service-postgresql-common/src/main/java/org/kie/kogito/jobs/service/repository/postgresql/PostgreSqlJobServiceManagementRepository.java +++ b/jobs-service/jobs-service-postgresql-common/src/main/java/org/kie/kogito/jobs/service/repository/postgresql/PostgreSqlJobServiceManagementRepository.java @@ -31,6 +31,7 @@ import io.smallrye.mutiny.Uni; import io.vertx.mutiny.pgclient.PgPool; import io.vertx.mutiny.sqlclient.Row; +import io.vertx.mutiny.sqlclient.RowIterator; import io.vertx.mutiny.sqlclient.RowSet; import io.vertx.mutiny.sqlclient.SqlClient; import io.vertx.mutiny.sqlclient.Tuple; @@ -99,4 +100,13 @@ public Uni heartbeat(JobServiceManagementInfo info) { .onItem().transform(iterator -> iterator.hasNext() ? from(iterator.next()) : null) .onItem().invoke(r -> LOGGER.trace("Heartbeat {}", r))); } + + @Override + public Uni release(JobServiceManagementInfo info) { + return client.withTransaction(conn -> conn + .preparedQuery("UPDATE job_service_management SET token = null, last_heartbeat = null WHERE id = $1 AND token = $2 RETURNING id, token, last_heartbeat") + .execute(Tuple.of(info.getId(), info.getToken())) + .onItem().transform(RowSet::iterator) + .onItem().transform(RowIterator::hasNext)); + } } diff --git a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java index 0634dc8442..2156126141 100644 --- a/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java +++ b/jobs-service/jobs-service-storage-jpa/src/main/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepository.java @@ -102,6 +102,11 @@ public Uni heartbeat(JobServiceManagementInfo info) { return Uni.createFrom().completionStage(this.reactiveRepositoryHelper.runAsync(() -> this.doHeartbeat(info))); } + @Override + public Uni release(JobServiceManagementInfo info) { + return Uni.createFrom().completionStage(this.reactiveRepositoryHelper.runAsync(() -> this.doRelease(info))); + } + private JobServiceManagementEntity findById(String id) { return repository.findById(id); } @@ -124,6 +129,17 @@ private JobServiceManagementInfo doHeartbeat(JobServiceManagementInfo info) { return from(jobService); } + private Boolean doRelease(JobServiceManagementInfo info) { + JobServiceManagementEntity jobService = findByIdAndToken(info); + if (jobService == null) { + return false; + } + jobService.setToken(null); + jobService.setLastHeartBeat(null); + repository.persist(jobService); + return true; + } + JobServiceManagementInfo from(JobServiceManagementEntity jobService) { if (Objects.isNull(jobService)) { return null; diff --git a/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java index a0a18cab45..4ca80a3df7 100644 --- a/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java +++ b/jobs-service/jobs-service-storage-jpa/src/test/java/org/kie/kogito/jobs/service/repository/jpa/JPAReactiveJobServiceManagementRepositoryTest.java @@ -104,4 +104,24 @@ void testConflictHeartbeat() { JobServiceManagementInfo updated = tested.heartbeat(new JobServiceManagementInfo(id, "differentToken", null)).await().indefinitely(); assertThat(updated).isNull(); } + + @Test + void testRelease() { + String id = "instance-id-5"; + String token = "token5"; + JobServiceManagementInfo created = create(id, token); + + Boolean released = tested.release(created).await().indefinitely(); + assertThat(released).isTrue(); + } + + @Test + void testReleaseNotExisting() { + String id = "instance-id-6"; + String token = "token6"; + JobServiceManagementInfo notExisting = new JobServiceManagementInfo(id, token, OffsetDateTime.now()); + + Boolean released = tested.release(notExisting).await().indefinitely(); + assertThat(released).isFalse(); + } } From fca48e5e2ab2d5404d3061c38a54ee5eb6c6e16d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tibor=20Zim=C3=A1nyi?= Date: Thu, 5 Sep 2024 09:35:05 +0200 Subject: [PATCH 3/4] Align with Quarkus 3.8.6 (#2097) Co-authored-by: jstastny-cz --- kogito-apps-build-parent/pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/kogito-apps-build-parent/pom.xml b/kogito-apps-build-parent/pom.xml index 2f5a4f365d..c5093d2eb0 100644 --- a/kogito-apps-build-parent/pom.xml +++ b/kogito-apps-build-parent/pom.xml @@ -62,7 +62,7 @@ ${project.version} - 6.4.4.Final + 6.4.8.Final 2.3.2 1.10.0 2.2.0 From ced181eab7374f918c5da31c512204387e996387 Mon Sep 17 00:00:00 2001 From: Roberto Oliveira Date: Thu, 5 Sep 2024 11:35:25 -0400 Subject: [PATCH 4/4] upgrade github workflows to use maven 3.9.6 --- .github/workflows/pr-downstream.yml | 2 +- .github/workflows/pr-kogito-apps.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/pr-downstream.yml b/.github/workflows/pr-downstream.yml index 0f2e24e924..c835156a22 100644 --- a/.github/workflows/pr-downstream.yml +++ b/.github/workflows/pr-downstream.yml @@ -23,7 +23,7 @@ jobs: job_name: [ kogito-quarkus-examples, kogito-springboot-examples, serverless-workflow-examples ] os: [ubuntu-latest] java-version: [17] - maven-version: ['3.9.3'] + maven-version: ['3.9.6'] include: - job_name: kogito-quarkus-examples repository: kogito-examples diff --git a/.github/workflows/pr-kogito-apps.yml b/.github/workflows/pr-kogito-apps.yml index 7fd543e4d3..96fb191fe9 100644 --- a/.github/workflows/pr-kogito-apps.yml +++ b/.github/workflows/pr-kogito-apps.yml @@ -22,7 +22,7 @@ jobs: matrix: os: [ubuntu-latest] java-version: [17] - maven-version: ['3.9.3'] + maven-version: ['3.9.6'] fail-fast: false runs-on: ${{ matrix.os }} name: ${{ matrix.os }} / Java-${{ matrix.java-version }} / Maven-${{ matrix.maven-version }}