diff --git a/build.gradle b/build.gradle index deb11a773d..8152aec1a9 100644 --- a/build.gradle +++ b/build.gradle @@ -66,7 +66,8 @@ dependencies { implementation 'io.seqera:lib-crypto:1.0.0' implementation 'io.seqera:jedis-lock:1.0.0' implementation 'io.seqera:lib-data-queue-redis:1.1.2' - implementation 'io.seqera:lib-data-stream-redis:1.2.0' + implementation 'io.seqera:lib-data-workqueue:2.0.0' + implementation 'io.seqera:lib-data-workqueue-redis:2.0.0' implementation 'io.seqera:lib-data-range-redis:1.1.0' implementation 'io.seqera:lib-jedis-pool:1.0.0' implementation 'io.micronaut:micronaut-http-client' diff --git a/docs/configuration.md b/docs/configuration.md index 118c6702f9..279fa819b9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -587,14 +587,17 @@ Configure polling and scheduling intervals for Wave's asynchronous job processin `wave.job-manager.scheduler-interval` *(optional)* : Interval for the job scheduler to process queued jobs (default: `1s`). -## Message stream +## Work queue -Configure how Wave consumes messages from the Redis stream used for internal event processing. +Configure how Wave consumes messages from the Redis backed work queue used for internal job processing. -`wave.message-stream.claim-timeout` *(optional)* -: Timeout for claiming messages from the Redis stream (default: `5s`). +`wave.work-queue.consumer-group-name` *(optional)* +: Name of the Redis consumer group used to read the work queues (default: `wave-message-stream`). -`wave.message-stream.consume-warn-timeout` *(optional)* +`wave.work-queue.visibility-timeout` *(optional)* +: How long a claimed message stays invisible to other consumers before it is redelivered (default: `5s`). + +`wave.work-queue.consumer-warn-timeout` *(optional)* : Threshold duration after which a slow message consumer triggers a warning (default: `4s`). ## Thread monitor diff --git a/src/main/groovy/io/seqera/wave/service/data/stream/RedisStreamConfigBean.groovy b/src/main/groovy/io/seqera/wave/service/data/stream/RedisStreamConfigBean.groovy deleted file mode 100644 index 785574cbd3..0000000000 --- a/src/main/groovy/io/seqera/wave/service/data/stream/RedisStreamConfigBean.groovy +++ /dev/null @@ -1,72 +0,0 @@ -/* - * Wave, containers provisioning service - * Copyright (c) 2023-2025, Seqera Labs - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -package io.seqera.wave.service.data.stream - -import java.time.Duration - -import groovy.transform.CompileStatic -import io.micronaut.context.annotation.Value -import io.seqera.data.stream.impl.RedisStreamConfig -import jakarta.inject.Singleton - -/** - * Configuration bean for Redis Stream messaging in Wave application. - * - *

This configuration provides centralized settings for Redis Stream-based - * messaging components, including consumer group management and timeout - * configurations. The configuration values are injected from application - * properties with sensible defaults. - * - *

Configuration properties: - *

- * - *

Example application configuration: - *

- * # application.yml
- * wave:
- *   message-stream:
- *     consumer-group-name: "wave-prod-stream"
- *     claim-timeout: "10s"
- *     consume-warn-timeout: "8s"
- * 
- * - *

This bean implements {@link RedisStreamConfig} to provide configuration - * values to Redis Stream components throughout the application. - * - * @author Paolo Di Tommaso - * @see io.seqera.data.stream.impl.RedisStreamConfig - */ -@CompileStatic -@Singleton -class RedisStreamConfigBean implements RedisStreamConfig { - - @Value('${wave.message-stream.consumer-group-name:wave-message-stream}') - String defaultConsumerGroupName - - @Value('${wave.message-stream.claim-timeout:5s}') - Duration claimTimeout - - @Value('${wave.message-stream.consume-warn-timeout:4s}') - Duration consumerWarnTimeout - -} diff --git a/src/main/groovy/io/seqera/wave/service/data/stream/BaseMessageStream.groovy b/src/main/groovy/io/seqera/wave/service/data/workqueue/BaseWorkQueue.groovy similarity index 58% rename from src/main/groovy/io/seqera/wave/service/data/stream/BaseMessageStream.groovy rename to src/main/groovy/io/seqera/wave/service/data/workqueue/BaseWorkQueue.groovy index 29a3d4b6e3..b3fbb6f83d 100644 --- a/src/main/groovy/io/seqera/wave/service/data/stream/BaseMessageStream.groovy +++ b/src/main/groovy/io/seqera/wave/service/data/workqueue/BaseWorkQueue.groovy @@ -16,50 +16,52 @@ * along with this program. If not, see . */ -package io.seqera.wave.service.data.stream +package io.seqera.wave.service.data.workqueue -import io.seqera.data.stream.AbstractMessageStream -import io.seqera.data.stream.MessageStream +import io.seqera.data.workqueue.AbstractWorkQueue +import io.seqera.data.workqueue.WorkQueue import io.seqera.lang.type.TypeHelper import io.seqera.serde.encode.StringEncodingStrategy import io.seqera.serde.moshi.MoshiEncodeStrategy /** - * Base abstract class for implementing message streams in the Wave application. - * - *

This class extends {@link AbstractMessageStream} and provides a foundation for - * creating type-safe message streams with automatic JSON serialization/deserialization + * Base abstract class for implementing work queues in the Wave application. + * + *

This class extends {@link AbstractWorkQueue} and provides a foundation for + * creating type-safe work queues with automatic JSON serialization/deserialization * using the Moshi library. It handles the encoding strategy configuration and - * provides a consistent interface for message stream implementations.

- * - *

Message streams are used for real-time message processing and event handling, - * allowing for continuous data flow and stream-based operations. Concrete implementations - * should extend this class to define specific streaming behavior and message processing logic.

- * - * @param the type of messages that this stream will handle + * provides a consistent interface for work queue implementations.

+ * + *

Work queues deliver each message to exactly one consumer at a time under a + * message lease: a message is removed from the queue only when the consumer settles + * it as acknowledged, and is otherwise redelivered once the lease expires. Concrete + * implementations should extend this class to define specific queueing behaviour and + * message processing logic.

+ * + * @param the type of messages that this queue will handle * @author Paolo Di Tommaso */ -abstract class BaseMessageStream extends AbstractMessageStream { +abstract class BaseWorkQueue extends AbstractWorkQueue { /** - * Constructs a new BaseMessageStream with the specified target stream. - * - * @param target the underlying string-based message stream that handles - * the actual message transport and streaming operations + * Constructs a new BaseWorkQueue with the specified target queue. + * + * @param target the underlying string-based work queue that handles + * the actual message transport and lease management */ - BaseMessageStream(MessageStream target) { + BaseWorkQueue(WorkQueue target) { super(target) } /** * Creates an instance of the required {@link StringEncodingStrategy} to serialize * and deserialize message events to/from JSON format. - * + * *

This method uses reflection to determine the generic type parameter {@code M} * and creates a Moshi-based encoding strategy that can handle the automatic * conversion between the strongly-typed message objects and their JSON string - * representations for streaming operations.

- * + * representations for queueing operations.

+ * * @return a new instance of {@link StringEncodingStrategy} configured for type {@code M} */ @Override diff --git a/src/main/groovy/io/seqera/wave/service/data/workqueue/RedisWorkQueueConfigBean.groovy b/src/main/groovy/io/seqera/wave/service/data/workqueue/RedisWorkQueueConfigBean.groovy new file mode 100644 index 0000000000..19c8193e7a --- /dev/null +++ b/src/main/groovy/io/seqera/wave/service/data/workqueue/RedisWorkQueueConfigBean.groovy @@ -0,0 +1,77 @@ +/* + * Wave, containers provisioning service + * Copyright (c) 2023-2025, Seqera Labs + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package io.seqera.wave.service.data.workqueue + +import java.time.Duration + +import groovy.transform.CompileStatic +import io.micronaut.context.annotation.Value +import io.seqera.data.workqueue.redis.RedisWorkQueueConfig +import jakarta.inject.Singleton + +/** + * Configuration bean for the Redis backed work queue in Wave application. + * + *

This configuration provides centralized settings for Redis work queue + * components, including consumer group management and lease timings. The + * configuration values are injected from application properties with sensible + * defaults. + * + *

Configuration properties: + *

    + *
  • {@code wave.work-queue.consumer-group-name} - Name for the Redis consumer group (default: "wave-message-stream")
  • + *
  • {@code wave.work-queue.visibility-timeout} - How long a claimed message stays invisible to other consumers before being redelivered (default: 5s)
  • + *
  • {@code wave.work-queue.consumer-warn-timeout} - Timeout threshold for consumer warnings (default: 4s)
  • + *
+ * + *

Note the consumer group name default is intentionally {@code wave-message-stream}: + * the work queue reuses the Redis streams and consumer group created by the message + * stream implementation it replaces, so messages queued before an upgrade are still + * delivered afterwards. + * + *

Example application configuration: + *

+ * # application.yml
+ * wave:
+ *   work-queue:
+ *     consumer-group-name: "wave-prod-queue"
+ *     visibility-timeout: "10s"
+ *     consumer-warn-timeout: "8s"
+ * 
+ * + *

This bean implements {@link RedisWorkQueueConfig} to provide configuration + * values to Redis work queue components throughout the application. + * + * @author Paolo Di Tommaso + * @see io.seqera.data.workqueue.redis.RedisWorkQueueConfig + */ +@CompileStatic +@Singleton +class RedisWorkQueueConfigBean implements RedisWorkQueueConfig { + + @Value('${wave.work-queue.consumer-group-name:wave-message-stream}') + String defaultConsumerGroupName + + @Value('${wave.work-queue.visibility-timeout:5s}') + Duration visibilityTimeout + + @Value('${wave.work-queue.consumer-warn-timeout:4s}') + Duration consumerWarnTimeout + +} diff --git a/src/main/groovy/io/seqera/wave/service/job/JobManager.groovy b/src/main/groovy/io/seqera/wave/service/job/JobManager.groovy index 9865f6b7d9..69aa36b822 100644 --- a/src/main/groovy/io/seqera/wave/service/job/JobManager.groovy +++ b/src/main/groovy/io/seqera/wave/service/job/JobManager.groovy @@ -29,6 +29,7 @@ import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Context import io.micronaut.context.annotation.Requires import io.micronaut.scheduling.TaskExecutors +import io.seqera.data.workqueue.MessageConsumer.Decision import io.seqera.util.trace.TraceElapsedTime import io.seqera.wave.configuration.JobManagerConfig import io.seqera.wave.configuration.WaveLite @@ -75,8 +76,10 @@ class JobManager { .expireAfterWrite(config.graceInterval.multipliedBy(2)) .executor(ioExecutor) .build() - pendingQueue.addConsumer((job)-> launchJob(job)) - processingQueue.addConsumer((job)-> processJob(job)) + // a `false` outcome settles the message as RETRY, leaving it queued for + // redelivery once the lease expires; `true` acknowledges and removes it + pendingQueue.addConsumer((job, lease)-> launchJob(job) ? Decision.ACK : Decision.RETRY) + processingQueue.addConsumer((job, lease)-> processJob(job) ? Decision.ACK : Decision.RETRY) } @TraceElapsedTime(thresholdMillis = '${wave.trace.k8s.threshold:500}') diff --git a/src/main/groovy/io/seqera/wave/service/job/JobPendingQueue.groovy b/src/main/groovy/io/seqera/wave/service/job/JobPendingQueue.groovy index 5b0712de98..ed99dd5232 100644 --- a/src/main/groovy/io/seqera/wave/service/job/JobPendingQueue.groovy +++ b/src/main/groovy/io/seqera/wave/service/job/JobPendingQueue.groovy @@ -23,13 +23,11 @@ import java.time.Duration import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Requires -import io.seqera.data.stream.MessageConsumer -import io.seqera.data.stream.MessageStream -import io.seqera.serde.encode.StringEncodingStrategy +import io.seqera.data.workqueue.MessageConsumer +import io.seqera.data.workqueue.WorkQueue import io.seqera.wave.configuration.JobManagerConfig import io.seqera.wave.configuration.WaveLite -import io.seqera.serde.moshi.MoshiEncodeStrategy -import io.seqera.wave.service.data.stream.BaseMessageStream +import io.seqera.wave.service.data.workqueue.BaseWorkQueue import jakarta.annotation.PreDestroy import jakarta.inject.Singleton /** @@ -42,17 +40,14 @@ import jakarta.inject.Singleton @Slf4j @Singleton @CompileStatic -class JobPendingQueue extends BaseMessageStream { +class JobPendingQueue extends BaseWorkQueue { - private final static String STREAM_NAME = 'jobs-pending/v2' - - private StringEncodingStrategy encoder + private final static String QUEUE_ID = 'jobs-pending/v2' private JobManagerConfig config - JobPendingQueue(MessageStream target, JobManagerConfig config) { + JobPendingQueue(WorkQueue target, JobManagerConfig config) { super(target) - this.encoder = new MoshiEncodeStrategy() {} this.config = config log.info "Created jobs pending queue - config=${config}" } @@ -68,15 +63,15 @@ class JobPendingQueue extends BaseMessageStream { } final void submit(JobSpec jobSpec) { - super.offer(STREAM_NAME, jobSpec) + super.offer(QUEUE_ID, jobSpec) } final void addConsumer(MessageConsumer consumer) { - super.addConsumer(STREAM_NAME, consumer) + super.addConsumer(QUEUE_ID, consumer) } final int length() { - return super.length(STREAM_NAME) + return super.length(QUEUE_ID) } @PreDestroy diff --git a/src/main/groovy/io/seqera/wave/service/job/JobProcessingQueue.groovy b/src/main/groovy/io/seqera/wave/service/job/JobProcessingQueue.groovy index 502bb9eeef..d6aa0ac703 100644 --- a/src/main/groovy/io/seqera/wave/service/job/JobProcessingQueue.groovy +++ b/src/main/groovy/io/seqera/wave/service/job/JobProcessingQueue.groovy @@ -23,11 +23,11 @@ import java.time.Duration import groovy.transform.CompileStatic import groovy.util.logging.Slf4j import io.micronaut.context.annotation.Requires -import io.seqera.data.stream.MessageConsumer -import io.seqera.data.stream.MessageStream +import io.seqera.data.workqueue.MessageConsumer +import io.seqera.data.workqueue.WorkQueue import io.seqera.wave.configuration.JobManagerConfig import io.seqera.wave.configuration.WaveLite -import io.seqera.wave.service.data.stream.BaseMessageStream +import io.seqera.wave.service.data.workqueue.BaseWorkQueue import jakarta.annotation.PreDestroy import jakarta.inject.Singleton /** @@ -39,15 +39,15 @@ import jakarta.inject.Singleton @Slf4j @Singleton @CompileStatic -class JobProcessingQueue extends BaseMessageStream { +class JobProcessingQueue extends BaseWorkQueue { private final static String QUEUE_NAME = "jobs-queue" - private final static String STREAM_NAME = "jobs-queue/v1" + private final static String QUEUE_ID = "jobs-queue/v1" private final JobManagerConfig config - JobProcessingQueue(MessageStream target, JobManagerConfig config) { + JobProcessingQueue(WorkQueue target, JobManagerConfig config) { super(target) this.config = config log.info "Created jobs processing queue - config=${config}" @@ -64,15 +64,15 @@ class JobProcessingQueue extends BaseMessageStream { } final void offer(JobSpec jobSpec) { - super.offer(STREAM_NAME, jobSpec) + super.offer(QUEUE_ID, jobSpec) } final void addConsumer(MessageConsumer consumer) { - super.addConsumer(STREAM_NAME, consumer) + super.addConsumer(QUEUE_ID, consumer) } final int length() { - return super.length(STREAM_NAME) + return super.length(QUEUE_ID) } @PreDestroy