From 2d7953c84b4e2371b74cf0da2cc0b7c71ded95ff Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Wed, 20 May 2026 16:30:00 -0700 Subject: [PATCH 01/23] added initial queues DAO methods --- .../transact/database/SystemDatabase.java | 16 ++ .../dbos/transact/database/dao/QueuesDAO.java | 168 ++++++++++++++++++ .../dev/dbos/transact/workflow/Queue.java | 76 ++++++-- .../dbos/transact/admin/AdminServerTest.java | 3 +- .../dev/dbos/transact/queue/QueuesTest.java | 11 +- 5 files changed, 257 insertions(+), 17 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index dfa0483b..e70fbf8e 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -385,6 +385,22 @@ public List getQueuePartitions(String queueName) { return dbRetry(() -> QueuesDAO.getQueuePartitions(ctx, queueName)); } + public boolean upsertQueue(Queue queue, boolean updateExisting) { + return dbRetry(() -> QueuesDAO.upsertQueue(ctx, queue, updateExisting)); + } + + public Optional getQueueFromDB(String name) { + return dbRetry(() -> QueuesDAO.getQueue(ctx, name)); + } + + public List listQueuesFromDB() { + return dbRetry(() -> QueuesDAO.listQueues(ctx)); + } + + public boolean deleteQueue(String name) { + return dbRetry(() -> QueuesDAO.deleteQueue(ctx, name)); + } + public StepResult checkStepResult(String workflowId, int functionId, String functionName) { return dbRetry( diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index f1544adf..c52b27ab 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -9,11 +9,13 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -293,4 +295,170 @@ public static List getQueuePartitions(DbContext ctx, String queueName) } } } + + /** + * Upsert a queue row. Returns true iff a new row was inserted (i.e. the queue did not previously + * exist). Returns false if the row already existed, regardless of whether it was updated. + */ + public static boolean upsertQueue(DbContext ctx, Queue queue, boolean updateExisting) + throws SQLException { + final String conflictClause = + updateExisting + ? """ + ON CONFLICT (name) DO UPDATE SET + concurrency = EXCLUDED.concurrency, + worker_concurrency = EXCLUDED.worker_concurrency, + rate_limit_max = EXCLUDED.rate_limit_max, + rate_limit_period_sec = EXCLUDED.rate_limit_period_sec, + priority_enabled = EXCLUDED.priority_enabled, + partition_queue = EXCLUDED.partition_queue, + polling_interval_sec = EXCLUDED.polling_interval_sec, + updated_at = EXCLUDED.updated_at + """ + : "ON CONFLICT (name) DO NOTHING"; + final String insertSql = + """ + INSERT INTO "%s".queues + (name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec, + priority_enabled, partition_queue, polling_interval_sec, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + %s + """ + .formatted(ctx.schema(), conflictClause); + final String existsSql = "SELECT 1 FROM \"%s\".queues WHERE name = ?".formatted(ctx.schema()); + + try (Connection connection = ctx.getConnection()) { + connection.setAutoCommit(false); + try { + boolean existed; + try (PreparedStatement ps = connection.prepareStatement(existsSql)) { + ps.setString(1, queue.name()); + try (ResultSet rs = ps.executeQuery()) { + existed = rs.next(); + } + } + + try (PreparedStatement ps = connection.prepareStatement(insertSql)) { + ps.setString(1, queue.name()); + setNullableInt(ps, 2, queue.concurrency()); + setNullableInt(ps, 3, queue.workerConcurrency()); + var rateLimit = queue.rateLimit(); + if (rateLimit != null) { + ps.setInt(4, rateLimit.limit()); + ps.setDouble(5, rateLimit.period().toMillis() / 1000.0); + } else { + ps.setNull(4, java.sql.Types.INTEGER); + ps.setNull(5, java.sql.Types.DOUBLE); + } + ps.setBoolean(6, queue.priorityEnabled()); + ps.setBoolean(7, queue.partitioningEnabled()); + var pollingInterval = queue.pollingInterval(); + if (pollingInterval != null) { + ps.setDouble(8, pollingInterval.toMillis() / 1000.0); + } else { + ps.setNull(8, java.sql.Types.DOUBLE); + } + ps.setLong(9, System.currentTimeMillis()); + ps.executeUpdate(); + } + + connection.commit(); + return !existed; + } catch (SQLException e) { + connection.rollback(); + throw e; + } + } + } + + public static Optional getQueue(DbContext ctx, String name) throws SQLException { + final String sql = + """ + SELECT name, concurrency, worker_concurrency, + rate_limit_max, rate_limit_period_sec, + priority_enabled, partition_queue, polling_interval_sec + FROM "%s".queues + WHERE name = ? + """ + .formatted(ctx.schema()); + + try (Connection connection = ctx.getConnection(); + PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, name); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(queueFromResultSet(rs)); + } + return Optional.empty(); + } + } + } + + public static List listQueues(DbContext ctx) throws SQLException { + final String sql = + """ + SELECT name, concurrency, worker_concurrency, + rate_limit_max, rate_limit_period_sec, + priority_enabled, partition_queue, polling_interval_sec + FROM "%s".queues + ORDER BY name + """ + .formatted(ctx.schema()); + + try (Connection connection = ctx.getConnection(); + PreparedStatement stmt = connection.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + List queues = new ArrayList<>(); + while (rs.next()) { + queues.add(queueFromResultSet(rs)); + } + return queues; + } + } + + public static boolean deleteQueue(DbContext ctx, String name) throws SQLException { + final String sql = "DELETE FROM \"%s\".queues WHERE name = ?".formatted(ctx.schema()); + + try (Connection connection = ctx.getConnection(); + PreparedStatement stmt = connection.prepareStatement(sql)) { + stmt.setString(1, name); + return stmt.executeUpdate() > 0; + } + } + + private static Queue queueFromResultSet(ResultSet rs) throws SQLException { + String name = rs.getString("name"); + Integer concurrency = rs.getObject("concurrency", Integer.class); + Integer workerConcurrency = rs.getObject("worker_concurrency", Integer.class); + Integer rateLimitMax = rs.getObject("rate_limit_max", Integer.class); + Double rateLimitPeriodSec = rs.getObject("rate_limit_period_sec", Double.class); + boolean priorityEnabled = rs.getBoolean("priority_enabled"); + boolean partitioningEnabled = rs.getBoolean("partition_queue"); + Double pollingIntervalSec = rs.getObject("polling_interval_sec", Double.class); + + Queue.RateLimit rateLimit = null; + if (rateLimitMax != null && rateLimitPeriodSec != null) { + rateLimit = + new Queue.RateLimit(rateLimitMax, Duration.ofMillis((long) (rateLimitPeriodSec * 1000))); + } + Duration pollingInterval = + pollingIntervalSec != null ? Duration.ofMillis((long) (pollingIntervalSec * 1000)) : null; + return new Queue( + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); + } + + private static void setNullableInt(PreparedStatement stmt, int index, Integer value) + throws SQLException { + if (value != null) { + stmt.setInt(index, value); + } else { + stmt.setNull(index, java.sql.Types.INTEGER); + } + } } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Queue.java b/transact/src/main/java/dev/dbos/transact/workflow/Queue.java index fe682ad1..baef4ea6 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Queue.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Queue.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.util.Objects; +import java.util.concurrent.TimeUnit; /** * Property definition for a DBOS workflow queue. Provides options for a name, concurrency and rate @@ -13,7 +14,8 @@ public record Queue( Integer workerConcurrency, boolean priorityEnabled, boolean partitioningEnabled, - RateLimit rateLimit) { + RateLimit rateLimit, + Duration pollingInterval) { /** Rate limit parameter structure for DBOS workflow queues */ public static record RateLimit(int limit, Duration period) {} @@ -26,11 +28,14 @@ public static record RateLimit(int limit, Duration period) {} if (workerConcurrency != null && workerConcurrency <= 0) throw new IllegalArgumentException( "If specified, queue workerConcurrency must be greater than zero"); + if (pollingInterval != null && (pollingInterval.isNegative() || pollingInterval.isZero())) + throw new IllegalArgumentException( + "If specified, queue pollingInterval must be greater than zero"); } /** Construct a queue with a given name */ public Queue(String name) { - this(name, null, null, false, false, null); + this(name, null, null, false, false, null, null); } /** @@ -43,7 +48,13 @@ public boolean hasLimiter() { /** Produces a new Queue with the assigned name. */ public Queue withName(String name) { return new Queue( - name, concurrency, workerConcurrency, priorityEnabled, partitioningEnabled, rateLimit); + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } /** @@ -52,7 +63,13 @@ public Queue withName(String name) { */ public Queue withConcurrency(Integer concurrency) { return new Queue( - name, concurrency, workerConcurrency, priorityEnabled, partitioningEnabled, rateLimit); + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } /** @@ -61,19 +78,37 @@ public Queue withConcurrency(Integer concurrency) { */ public Queue withWorkerConcurrency(Integer workerConcurrency) { return new Queue( - name, concurrency, workerConcurrency, priorityEnabled, partitioningEnabled, rateLimit); + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } /** Produces a new Queue with the prioritization enabled/disabled. */ public Queue withPriorityEnabled(boolean priorityEnabled) { return new Queue( - name, concurrency, workerConcurrency, priorityEnabled, partitioningEnabled, rateLimit); + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } /** Produces a new Queue with the partitioned enabled/disabled. */ public Queue withPartitioningEnabled(boolean partitioningEnabled) { return new Queue( - name, concurrency, workerConcurrency, priorityEnabled, partitioningEnabled, rateLimit); + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } /** @@ -82,7 +117,13 @@ public Queue withPartitioningEnabled(boolean partitioningEnabled) { */ public Queue withRateLimit(RateLimit rateLimit) { return new Queue( - name, concurrency, workerConcurrency, priorityEnabled, partitioningEnabled, rateLimit); + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } /** @@ -92,10 +133,23 @@ public Queue withRateLimit(int limit, Duration period) { return withRateLimit(new RateLimit(limit, period)); } + /** Produces a new Queue with the assigned rate limit, expressed in workflows per period. */ + public Queue withRateLimit(int limit, long period, TimeUnit unit) { + return withRateLimit(new RateLimit(limit, Duration.of(period, unit.toChronoUnit()))); + } + /** - * Produces a new Queue with the assigned rate limit, expressed in workflows per period (seconds). + * Produces a new Queue with the assigned polling interval. `null` may be specified to use the + * executor default (1 second). */ - public Queue withRateLimit(int limit, double period) { - return withRateLimit(new RateLimit(limit, Duration.ofMillis((long) (period * 1000)))); + public Queue withPollingInterval(Duration pollingInterval) { + return new Queue( + name, + concurrency, + workerConcurrency, + priorityEnabled, + partitioningEnabled, + rateLimit, + pollingInterval); } } diff --git a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java index bdfcf0f4..6c8421de 100644 --- a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java +++ b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java @@ -32,6 +32,7 @@ import java.util.ArrayList; import java.util.List; import java.util.Map; +import java.util.concurrent.TimeUnit; import io.restassured.response.Response; import org.junit.jupiter.api.BeforeEach; @@ -197,7 +198,7 @@ public void queueMetadata() throws IOException { .withConcurrency(10) .withWorkerConcurrency(5) .withPriorityEnabled(true) - .withRateLimit(2, 4.0); + .withRateLimit(2, 4, TimeUnit.SECONDS); when(mockExec.getQueues()).thenReturn(List.of(queue1, queue2)); diff --git a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java index 1f7cabd6..b519935f 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java @@ -336,7 +336,8 @@ public void multipleQueues() throws Exception { public void testLimiter() throws Exception { int limit = 5; - double period = 1.8; // + double periodSec = 1.8; + Duration period = Duration.ofMillis((long) (periodSec * 1000)); Queue limitQ = new Queue("limitQueue") @@ -392,15 +393,15 @@ public void testLimiter() throws Exception { double gap = startOfNextWave - startOfCurrentWave; logger.info(String.format("Gap between Wave %d and %d: %.3f", wave, wave + 1, gap)); assertTrue( - gap > period - periodTolerance, + gap > periodSec - periodTolerance, String.format( "Gap between wave %d and %d should be at least %.3f. Actual: %.3f", - wave, wave + 1, period - periodTolerance, gap)); + wave, wave + 1, periodSec - periodTolerance, gap)); assertTrue( - gap < period + periodTolerance, + gap < periodSec + periodTolerance, String.format( "Gap between wave %d and %d should be at most %.3f. Actual: %.3f", - wave, wave + 1, period + periodTolerance, gap)); + wave, wave + 1, periodSec + periodTolerance, gap)); } for (WorkflowHandle h : handles) { From c4c5348caa026604c7853b4521ee6ac17b509f69 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Wed, 20 May 2026 16:41:17 -0700 Subject: [PATCH 02/23] updateQueue --- .../transact/database/SystemDatabase.java | 5 + .../dbos/transact/database/dao/QueuesDAO.java | 53 +++++- .../dev/dbos/transact/workflow/Field.java | 30 +++ .../dev/dbos/transact/workflow/Queue.java | 15 +- .../dbos/transact/workflow/QueueUpdate.java | 137 ++++++++++++++ .../transact/database/SystemDatabaseTest.java | 175 ++++++++++++++++++ 6 files changed, 400 insertions(+), 15 deletions(-) create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/Field.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index e70fbf8e..5388d1e9 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -22,6 +22,7 @@ import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.NotificationInfo; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueUpdate; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.VersionInfo; @@ -389,6 +390,10 @@ public boolean upsertQueue(Queue queue, boolean updateExisting) { return dbRetry(() -> QueuesDAO.upsertQueue(ctx, queue, updateExisting)); } + public void updateQueue(String name, QueueUpdate update) { + dbRetry(() -> QueuesDAO.updateQueue(ctx, name, update)); + } + public Optional getQueueFromDB(String name) { return dbRetry(() -> QueuesDAO.getQueue(ctx, name)); } diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index c52b27ab..6febc173 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -1,7 +1,9 @@ package dev.dbos.transact.database.dao; import dev.dbos.transact.database.DbContext; +import dev.dbos.transact.workflow.Field; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueUpdate; import dev.dbos.transact.workflow.WorkflowState; import java.sql.Connection; @@ -352,12 +354,7 @@ ON CONFLICT (name) DO UPDATE SET } ps.setBoolean(6, queue.priorityEnabled()); ps.setBoolean(7, queue.partitioningEnabled()); - var pollingInterval = queue.pollingInterval(); - if (pollingInterval != null) { - ps.setDouble(8, pollingInterval.toMillis() / 1000.0); - } else { - ps.setNull(8, java.sql.Types.DOUBLE); - } + ps.setDouble(8, queue.pollingInterval().toMillis() / 1000.0); ps.setLong(9, System.currentTimeMillis()); ps.executeUpdate(); } @@ -416,6 +413,46 @@ public static List listQueues(DbContext ctx) throws SQLException { } } + public static void updateQueue(DbContext ctx, String name, QueueUpdate update) + throws SQLException { + if (update.isEmpty()) return; + + List setClauses = new ArrayList<>(); + List params = new ArrayList<>(); + + collectField(setClauses, params, "concurrency", update.concurrency()); + collectField(setClauses, params, "worker_concurrency", update.workerConcurrency()); + collectField(setClauses, params, "rate_limit_max", update.rateLimitMax()); + collectField(setClauses, params, "rate_limit_period_sec", update.rateLimitPeriodSec()); + collectField(setClauses, params, "priority_enabled", update.priorityEnabled()); + collectField(setClauses, params, "partition_queue", update.partitionQueue()); + collectField(setClauses, params, "polling_interval_sec", update.pollingIntervalSec()); + + setClauses.add("\"updated_at\" = ?"); + params.add(System.currentTimeMillis()); + params.add(name); + + String sql = + "UPDATE \"%s\".queues SET %s WHERE name = ?" + .formatted(ctx.schema(), String.join(", ", setClauses)); + + try (Connection connection = ctx.getConnection(); + PreparedStatement ps = connection.prepareStatement(sql)) { + for (int i = 0; i < params.size(); i++) { + ps.setObject(i + 1, params.get(i)); + } + ps.executeUpdate(); + } + } + + private static void collectField( + List clauses, List params, String column, Field field) { + if (field.isPresent()) { + clauses.add("\"" + column + "\" = ?"); + params.add(field.get()); + } + } + public static boolean deleteQueue(DbContext ctx, String name) throws SQLException { final String sql = "DELETE FROM \"%s\".queues WHERE name = ?".formatted(ctx.schema()); @@ -442,7 +479,9 @@ private static Queue queueFromResultSet(ResultSet rs) throws SQLException { new Queue.RateLimit(rateLimitMax, Duration.ofMillis((long) (rateLimitPeriodSec * 1000))); } Duration pollingInterval = - pollingIntervalSec != null ? Duration.ofMillis((long) (pollingIntervalSec * 1000)) : null; + pollingIntervalSec != null + ? Duration.ofMillis((long) (pollingIntervalSec * 1000)) + : Queue.DEFAULT_POLLING_INTERVAL; return new Queue( name, concurrency, diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Field.java b/transact/src/main/java/dev/dbos/transact/workflow/Field.java new file mode 100644 index 00000000..a5f3ff82 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/Field.java @@ -0,0 +1,30 @@ +package dev.dbos.transact.workflow; + +import org.jspecify.annotations.Nullable; + +/** + * Three-state field for partial updates: absent (don't touch), present with a value, or present + * with null (clear the column). + */ +public sealed interface Field permits Field.Absent, Field.Present { + + record Absent() implements Field {} + + record Present(@Nullable T value) implements Field {} + + static Field absent() { + return new Absent<>(); + } + + static Field of(@Nullable T value) { + return new Present<>(value); + } + + default boolean isPresent() { + return this instanceof Present; + } + + default @Nullable T get() { + return ((Present) this).value(); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Queue.java b/transact/src/main/java/dev/dbos/transact/workflow/Queue.java index baef4ea6..7a07ab40 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Queue.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Queue.java @@ -17,25 +17,27 @@ public record Queue( RateLimit rateLimit, Duration pollingInterval) { + public static final Duration DEFAULT_POLLING_INTERVAL = Duration.ofSeconds(1); + /** Rate limit parameter structure for DBOS workflow queues */ public static record RateLimit(int limit, Duration period) {} public Queue { Objects.requireNonNull(name, "Queue name must not be null"); + Objects.requireNonNull(pollingInterval, "Queue pollingInterval must not be null"); if (concurrency != null && concurrency <= 0) throw new IllegalArgumentException( "If specified, queue concurrency must be greater than zero"); if (workerConcurrency != null && workerConcurrency <= 0) throw new IllegalArgumentException( "If specified, queue workerConcurrency must be greater than zero"); - if (pollingInterval != null && (pollingInterval.isNegative() || pollingInterval.isZero())) - throw new IllegalArgumentException( - "If specified, queue pollingInterval must be greater than zero"); + if (pollingInterval.isNegative() || pollingInterval.isZero()) + throw new IllegalArgumentException("Queue pollingInterval must be greater than zero"); } /** Construct a queue with a given name */ public Queue(String name) { - this(name, null, null, false, false, null, null); + this(name, null, null, false, false, null, DEFAULT_POLLING_INTERVAL); } /** @@ -138,10 +140,7 @@ public Queue withRateLimit(int limit, long period, TimeUnit unit) { return withRateLimit(new RateLimit(limit, Duration.of(period, unit.toChronoUnit()))); } - /** - * Produces a new Queue with the assigned polling interval. `null` may be specified to use the - * executor default (1 second). - */ + /** Produces a new Queue with the assigned polling interval. */ public Queue withPollingInterval(Duration pollingInterval) { return new Queue( name, diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java new file mode 100644 index 00000000..5dda0fd8 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java @@ -0,0 +1,137 @@ +package dev.dbos.transact.workflow; + +import org.jspecify.annotations.Nullable; + +/** Partial update specification for a queue row. Only present fields are written to the DB. */ +public record QueueUpdate( + Field concurrency, + Field workerConcurrency, + Field rateLimitMax, + Field rateLimitPeriodSec, + Field priorityEnabled, + Field partitionQueue, + Field pollingIntervalSec) { + + private static final QueueUpdate EMPTY = + new QueueUpdate( + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent()); + + public boolean isEmpty() { + return !concurrency.isPresent() + && !workerConcurrency.isPresent() + && !rateLimitMax.isPresent() + && !rateLimitPeriodSec.isPresent() + && !priorityEnabled.isPresent() + && !partitionQueue.isPresent() + && !pollingIntervalSec.isPresent(); + } + + public static QueueUpdate setConcurrency(@Nullable Integer value) { + return EMPTY.withConcurrency(Field.of(value)); + } + + public static QueueUpdate setWorkerConcurrency(@Nullable Integer value) { + return EMPTY.withWorkerConcurrency(Field.of(value)); + } + + public static QueueUpdate setRateLimit(@Nullable Integer max, @Nullable Double periodSec) { + return EMPTY.withRateLimitMax(Field.of(max)).withRateLimitPeriodSec(Field.of(periodSec)); + } + + public static QueueUpdate setPriorityEnabled(boolean value) { + return EMPTY.withPriorityEnabled(Field.of(value)); + } + + public static QueueUpdate setPartitionQueue(boolean value) { + return EMPTY.withPartitionQueue(Field.of(value)); + } + + public static QueueUpdate setPollingIntervalSec(@Nullable Double value) { + return EMPTY.withPollingIntervalSec(Field.of(value)); + } + + // --- with* builders for chaining --- + + public QueueUpdate withConcurrency(Field concurrency) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } + + public QueueUpdate withWorkerConcurrency(Field workerConcurrency) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } + + public QueueUpdate withRateLimitMax(Field rateLimitMax) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } + + public QueueUpdate withRateLimitPeriodSec(Field rateLimitPeriodSec) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } + + public QueueUpdate withPriorityEnabled(Field priorityEnabled) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } + + public QueueUpdate withPartitionQueue(Field partitionQueue) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } + + public QueueUpdate withPollingIntervalSec(Field pollingIntervalSec) { + return new QueueUpdate( + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriodSec, + priorityEnabled, + partitionQueue, + pollingIntervalSec); + } +} diff --git a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java index 73396c7c..742ebc91 100644 --- a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java +++ b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java @@ -21,8 +21,11 @@ import dev.dbos.transact.utils.WorkflowStatusBuilder; import dev.dbos.transact.utils.WorkflowStatusInternalBuilder; import dev.dbos.transact.workflow.ExportedWorkflow; +import dev.dbos.transact.workflow.Field; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.GetWorkflowAggregatesInput; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueUpdate; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.VersionInfo; import dev.dbos.transact.workflow.WorkflowDelay; @@ -1566,4 +1569,176 @@ public void testGetAllNotificationsEmpty() throws Exception { var notifications = sysdb.getAllNotifications(wfId); assertTrue(notifications.isEmpty()); } + + // --- Queue CRUD tests --- + + @Test + public void testUpsertQueueInsert() { + var queue = + new Queue("q-insert") + .withConcurrency(5) + .withWorkerConcurrency(2) + .withPriorityEnabled(true) + .withRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS); + + boolean inserted = sysdb.upsertQueue(queue, true); + assertTrue(inserted, "upsertQueue should return true when the row is new"); + + var fetched = sysdb.getQueueFromDB("q-insert"); + assertTrue(fetched.isPresent()); + var q = fetched.get(); + assertEquals("q-insert", q.name()); + assertEquals(5, q.concurrency()); + assertEquals(2, q.workerConcurrency()); + assertTrue(q.priorityEnabled()); + assertNotNull(q.rateLimit()); + assertEquals(10, q.rateLimit().limit()); + assertEquals(Duration.ofSeconds(60), q.rateLimit().period()); + } + + @Test + public void testUpsertQueueUpdateExisting() { + var queue = new Queue("q-update").withConcurrency(3); + sysdb.upsertQueue(queue, true); + + var updated = new Queue("q-update").withConcurrency(7).withWorkerConcurrency(4); + boolean inserted = sysdb.upsertQueue(updated, true); + assertFalse(inserted, "upsertQueue should return false when the row already existed"); + + var fetched = sysdb.getQueueFromDB("q-update").orElseThrow(); + assertEquals(7, fetched.concurrency()); + assertEquals(4, fetched.workerConcurrency()); + } + + @Test + public void testUpsertQueueNoUpdateExisting() { + var queue = new Queue("q-no-update").withConcurrency(3); + sysdb.upsertQueue(queue, true); + + var attempted = new Queue("q-no-update").withConcurrency(99); + boolean inserted = sysdb.upsertQueue(attempted, false); + assertFalse(inserted, "upsertQueue should return false when the row already existed"); + + var fetched = sysdb.getQueueFromDB("q-no-update").orElseThrow(); + assertEquals( + 3, fetched.concurrency(), "concurrency should be unchanged when updateExisting=false"); + } + + @Test + public void testGetQueueFromDBMissing() { + var result = sysdb.getQueueFromDB("does-not-exist"); + assertTrue(result.isEmpty()); + } + + @Test + public void testListQueuesFromDB() { + sysdb.upsertQueue(new Queue("q-list-a").withConcurrency(1), true); + sysdb.upsertQueue(new Queue("q-list-b").withConcurrency(2), true); + sysdb.upsertQueue(new Queue("q-list-c"), true); + + var queues = sysdb.listQueuesFromDB(); + var names = queues.stream().map(Queue::name).toList(); + assertTrue(names.contains("q-list-a")); + assertTrue(names.contains("q-list-b")); + assertTrue(names.contains("q-list-c")); + } + + @Test + public void testDeleteQueue() { + sysdb.upsertQueue(new Queue("q-delete").withConcurrency(1), true); + assertTrue(sysdb.getQueueFromDB("q-delete").isPresent()); + + boolean deleted = sysdb.deleteQueue("q-delete"); + assertTrue(deleted); + assertTrue(sysdb.getQueueFromDB("q-delete").isEmpty()); + } + + @Test + public void testDeleteQueueMissing() { + assertFalse(sysdb.deleteQueue("q-never-existed")); + } + + @Test + public void testUpdateQueuePartialConcurrency() { + sysdb.upsertQueue( + new Queue("q-partial") + .withConcurrency(5) + .withPriorityEnabled(true) + .withRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS), + true); + + sysdb.updateQueue("q-partial", QueueUpdate.setConcurrency(99)); + + var q = sysdb.getQueueFromDB("q-partial").orElseThrow(); + assertEquals(99, q.concurrency(), "concurrency should be updated"); + assertTrue(q.priorityEnabled(), "priorityEnabled should be unchanged"); + assertNotNull(q.rateLimit(), "rateLimit should be unchanged"); + assertEquals(10, q.rateLimit().limit()); + } + + @Test + public void testUpdateQueueClearConcurrency() { + sysdb.upsertQueue(new Queue("q-clear-conc").withConcurrency(5), true); + + sysdb.updateQueue("q-clear-conc", QueueUpdate.setConcurrency(null)); + + var q = sysdb.getQueueFromDB("q-clear-conc").orElseThrow(); + assertNull(q.concurrency(), "concurrency should be cleared to null"); + } + + @Test + public void testUpdateQueueClearRateLimit() { + sysdb.upsertQueue( + new Queue("q-clear-rate").withRateLimit(5, 30, java.util.concurrent.TimeUnit.SECONDS), + true); + + sysdb.updateQueue("q-clear-rate", QueueUpdate.setRateLimit(null, null)); + + var q = sysdb.getQueueFromDB("q-clear-rate").orElseThrow(); + assertNull(q.rateLimit(), "rateLimit should be cleared to null"); + } + + @Test + public void testUpdateQueueEmpty() { + sysdb.upsertQueue(new Queue("q-empty-update").withConcurrency(5), true); + + // Empty update should be a no-op (no exception, no change) + var emptyUpdate = + new QueueUpdate( + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent()); + sysdb.updateQueue("q-empty-update", emptyUpdate); + + var q = sysdb.getQueueFromDB("q-empty-update").orElseThrow(); + assertEquals(5, q.concurrency()); + } + + @Test + public void testUpsertQueueRoundTrip() { + var original = + new Queue("q-roundtrip") + .withConcurrency(8) + .withWorkerConcurrency(4) + .withPriorityEnabled(true) + .withPartitioningEnabled(true) + .withRateLimit(20, 30, java.util.concurrent.TimeUnit.SECONDS) + .withPollingInterval(Duration.ofSeconds(5)); + + sysdb.upsertQueue(original, true); + var fetched = sysdb.getQueueFromDB("q-roundtrip").orElseThrow(); + + assertEquals(original.name(), fetched.name()); + assertEquals(original.concurrency(), fetched.concurrency()); + assertEquals(original.workerConcurrency(), fetched.workerConcurrency()); + assertEquals(original.priorityEnabled(), fetched.priorityEnabled()); + assertEquals(original.partitioningEnabled(), fetched.partitioningEnabled()); + assertEquals(original.rateLimit().limit(), fetched.rateLimit().limit()); + assertEquals(original.rateLimit().period(), fetched.rateLimit().period()); + assertEquals(original.pollingInterval(), fetched.pollingInterval()); + } } From 24e044c69f2a8a73dc499af1c012422e0cb048a4 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Wed, 20 May 2026 16:49:15 -0700 Subject: [PATCH 03/23] dynamic queues conductor protocol --- .../dbos/transact/conductor/Conductor.java | 32 +++++ .../conductor/protocol/BaseMessage.java | 2 + .../conductor/protocol/GetQueueRequest.java | 10 ++ .../conductor/protocol/GetQueueResponse.java | 17 +++ .../conductor/protocol/ListQueuesRequest.java | 8 ++ .../protocol/ListQueuesResponse.java | 20 +++ .../conductor/protocol/MessageType.java | 2 + .../conductor/protocol/QueueOutput.java | 27 ++++ .../transact/database/SystemDatabase.java | 4 +- .../transact/conductor/ConductorTest.java | 132 ++++++++++++++++++ .../transact/database/SystemDatabaseTest.java | 24 ++-- 11 files changed, 264 insertions(+), 14 deletions(-) create mode 100644 transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueRequest.java create mode 100644 transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueResponse.java create mode 100644 transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesRequest.java create mode 100644 transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesResponse.java create mode 100644 transact/src/main/java/dev/dbos/transact/conductor/protocol/QueueOutput.java diff --git a/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java b/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java index 216cc80f..021f3fbb 100644 --- a/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java +++ b/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java @@ -6,6 +6,7 @@ import dev.dbos.transact.workflow.ExportedWorkflow; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; +import dev.dbos.transact.workflow.Queue; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowSchedule; @@ -698,6 +699,7 @@ CompletableFuture getResponseAsync(BaseMessage message, WebSocket case EXPORT_WORKFLOW -> handleExportWorkflow(this, message, ws); case FORK_WORKFLOW -> handleFork(this, message); case GET_METRICS -> handleGetMetrics(this, message); + case GET_QUEUE -> handleGetQueue(this, message); case GET_SCHEDULE -> handleGetSchedule(this, message); case GET_WORKFLOW_AGGREGATES -> handleGetWorkflowAggregates(this, message); case GET_WORKFLOW_EVENTS -> handleGetWorkflowEvents(this, message); @@ -707,6 +709,7 @@ CompletableFuture getResponseAsync(BaseMessage message, WebSocket case IMPORT_WORKFLOW -> handleImportWorkflow(this, message); case LIST_APPLICATION_VERSIONS -> handleListApplicationVersions(this, message); case LIST_QUEUED_WORKFLOWS -> handleListQueuedWorkflows(this, message); + case LIST_QUEUES -> handleListQueues(this, message); case LIST_SCHEDULES -> handleListSchedules(this, message); case LIST_STEPS -> handleListSteps(this, message); case LIST_WORKFLOWS -> handleListWorkflows(this, message); @@ -1364,6 +1367,35 @@ static CompletableFuture handleGetSchedule( }); } + static CompletableFuture handleListQueues( + Conductor conductor, BaseMessage message) { + return CompletableFuture.supplyAsync( + () -> { + try { + List queues = conductor.systemDatabase.listQueues(); + List output = queues.stream().map(QueueOutput::from).toList(); + return new ListQueuesResponse(message, output); + } catch (Exception e) { + logger.error("Exception encountered when listing queues", e); + return new ListQueuesResponse(message, e.getMessage()); + } + }); + } + + static CompletableFuture handleGetQueue(Conductor conductor, BaseMessage message) { + return CompletableFuture.supplyAsync( + () -> { + GetQueueRequest request = (GetQueueRequest) message; + try { + var queue = conductor.systemDatabase.getQueue(request.name); + return new GetQueueResponse(request, queue.map(QueueOutput::from).orElse(null)); + } catch (Exception e) { + logger.error("Exception encountered when getting queue {}", request.name, e); + return new GetQueueResponse(request, e.getMessage()); + } + }); + } + static CompletableFuture handlePauseSchedule( Conductor conductor, BaseMessage message) { return CompletableFuture.supplyAsync( diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/BaseMessage.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/BaseMessage.java index 41835223..97fb7e6e 100644 --- a/transact/src/main/java/dev/dbos/transact/conductor/protocol/BaseMessage.java +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/BaseMessage.java @@ -19,6 +19,7 @@ @JsonSubTypes.Type(value = ExportWorkflowRequest.class, name = "export_workflow"), @JsonSubTypes.Type(value = ForkWorkflowRequest.class, name = "fork_workflow"), @JsonSubTypes.Type(value = GetMetricsRequest.class, name = "get_metrics"), + @JsonSubTypes.Type(value = GetQueueRequest.class, name = "get_queue"), @JsonSubTypes.Type(value = GetScheduleRequest.class, name = "get_schedule"), @JsonSubTypes.Type(value = GetWorkflowAggregatesRequest.class, name = "get_workflow_aggregates"), @JsonSubTypes.Type(value = GetWorkflowEventsRequest.class, name = "get_workflow_events"), @@ -32,6 +33,7 @@ value = ListApplicationVersionsRequest.class, name = "list_application_versions"), @JsonSubTypes.Type(value = ListQueuedWorkflowsRequest.class, name = "list_queued_workflows"), + @JsonSubTypes.Type(value = ListQueuesRequest.class, name = "list_queues"), @JsonSubTypes.Type(value = ListSchedulesRequest.class, name = "list_schedules"), @JsonSubTypes.Type(value = ListStepsRequest.class, name = "list_steps"), @JsonSubTypes.Type(value = ListWorkflowsRequest.class, name = "list_workflows"), diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueRequest.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueRequest.java new file mode 100644 index 00000000..69da526f --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueRequest.java @@ -0,0 +1,10 @@ +package dev.dbos.transact.conductor.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class GetQueueRequest extends BaseMessage { + public String name; + + public GetQueueRequest() {} +} diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueResponse.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueResponse.java new file mode 100644 index 00000000..a31cca2f --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/GetQueueResponse.java @@ -0,0 +1,17 @@ +package dev.dbos.transact.conductor.protocol; + +public class GetQueueResponse extends BaseResponse { + public QueueOutput output; + + public GetQueueResponse() {} + + public GetQueueResponse(BaseMessage message, QueueOutput output) { + super(message.type, message.request_id); + this.output = output; + } + + public GetQueueResponse(BaseMessage message, String errorMessage) { + super(message.type, message.request_id, errorMessage); + this.output = null; + } +} diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesRequest.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesRequest.java new file mode 100644 index 00000000..8e787ecf --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesRequest.java @@ -0,0 +1,8 @@ +package dev.dbos.transact.conductor.protocol; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class ListQueuesRequest extends BaseMessage { + public ListQueuesRequest() {} +} diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesResponse.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesResponse.java new file mode 100644 index 00000000..ed45fe47 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/ListQueuesResponse.java @@ -0,0 +1,20 @@ +package dev.dbos.transact.conductor.protocol; + +import java.util.Collections; +import java.util.List; + +public class ListQueuesResponse extends BaseResponse { + public List output; + + public ListQueuesResponse() {} + + public ListQueuesResponse(BaseMessage message, List output) { + super(message.type, message.request_id); + this.output = output; + } + + public ListQueuesResponse(BaseMessage message, String errorMessage) { + super(message.type, message.request_id, errorMessage); + this.output = Collections.emptyList(); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/MessageType.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/MessageType.java index a07463ff..957e351e 100644 --- a/transact/src/main/java/dev/dbos/transact/conductor/protocol/MessageType.java +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/MessageType.java @@ -10,6 +10,7 @@ public enum MessageType { EXPORT_WORKFLOW("export_workflow"), FORK_WORKFLOW("fork_workflow"), GET_METRICS("get_metrics"), + GET_QUEUE("get_queue"), GET_SCHEDULE("get_schedule"), GET_WORKFLOW_AGGREGATES("get_workflow_aggregates"), GET_WORKFLOW_EVENTS("get_workflow_events"), @@ -19,6 +20,7 @@ public enum MessageType { IMPORT_WORKFLOW("import_workflow"), LIST_APPLICATION_VERSIONS("list_application_versions"), LIST_QUEUED_WORKFLOWS("list_queued_workflows"), + LIST_QUEUES("list_queues"), LIST_SCHEDULES("list_schedules"), LIST_STEPS("list_steps"), LIST_WORKFLOWS("list_workflows"), diff --git a/transact/src/main/java/dev/dbos/transact/conductor/protocol/QueueOutput.java b/transact/src/main/java/dev/dbos/transact/conductor/protocol/QueueOutput.java new file mode 100644 index 00000000..f8c97dee --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/conductor/protocol/QueueOutput.java @@ -0,0 +1,27 @@ +package dev.dbos.transact.conductor.protocol; + +import dev.dbos.transact.workflow.Queue; + +public record QueueOutput( + String name, + Integer concurrency, + Integer worker_concurrency, + Integer rate_limit_max, + Double rate_limit_period_sec, + boolean priority_enabled, + boolean partition_queue, + double polling_interval_sec) { + + public static QueueOutput from(Queue q) { + Queue.RateLimit rl = q.rateLimit(); + return new QueueOutput( + q.name(), + q.concurrency(), + q.workerConcurrency(), + rl != null ? rl.limit() : null, + rl != null ? rl.period().toMillis() / 1000.0 : null, + q.priorityEnabled(), + q.partitioningEnabled(), + q.pollingInterval().toMillis() / 1000.0); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index 5388d1e9..85553ba4 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -394,11 +394,11 @@ public void updateQueue(String name, QueueUpdate update) { dbRetry(() -> QueuesDAO.updateQueue(ctx, name, update)); } - public Optional getQueueFromDB(String name) { + public Optional getQueue(String name) { return dbRetry(() -> QueuesDAO.getQueue(ctx, name)); } - public List listQueuesFromDB() { + public List listQueues() { return dbRetry(() -> QueuesDAO.listQueues(ctx)); } diff --git a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java index 1346a5ca..dcba59db 100644 --- a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java +++ b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java @@ -3267,4 +3267,136 @@ public void canGetWorkflowStreamsThrows() throws Exception { assertEquals(0, json.get("streams").size()); } } + + @RetryingTest(3) + public void canListQueues() throws Exception { + MessageListener listener = new MessageListener(); + testServer.setListener(listener); + + dev.dbos.transact.workflow.Queue q1 = + new dev.dbos.transact.workflow.Queue("queue-1") + .withConcurrency(5) + .withWorkerConcurrency(2) + .withRateLimit(10, Duration.ofSeconds(60)) + .withPriorityEnabled(true) + .withPartitioningEnabled(true) + .withPollingInterval(Duration.ofMillis(500)); + dev.dbos.transact.workflow.Queue q2 = new dev.dbos.transact.workflow.Queue("queue-2"); + when(mockDB.listQueues()).thenReturn(List.of(q1, q2)); + + try (Conductor conductor = builder.build()) { + conductor.start(); + assertTrue(listener.openLatch.await(5, TimeUnit.SECONDS), "open latch timed out"); + + listener.send(MessageType.LIST_QUEUES, "req-list-queues", Map.of()); + assertTrue(listener.messageLatch.await(1, TimeUnit.SECONDS), "message latch timed out"); + + verify(mockDB).listQueues(); + + JsonNode json = mapper.readTree(listener.message); + assertEquals("list_queues", json.get("type").asText()); + assertEquals("req-list-queues", json.get("request_id").asText()); + assertNull(json.get("error_message")); + + JsonNode output = json.get("output"); + assertNotNull(output); + assertTrue(output.isArray()); + assertEquals(2, output.size()); + + JsonNode first = output.get(0); + assertEquals("queue-1", first.get("name").asText()); + assertEquals(5, first.get("concurrency").asInt()); + assertEquals(2, first.get("worker_concurrency").asInt()); + assertEquals(10, first.get("rate_limit_max").asInt()); + assertEquals(60.0, first.get("rate_limit_period_sec").asDouble(), 0.001); + assertTrue(first.get("priority_enabled").asBoolean()); + assertTrue(first.get("partition_queue").asBoolean()); + assertEquals(0.5, first.get("polling_interval_sec").asDouble(), 0.001); + + JsonNode second = output.get(1); + assertEquals("queue-2", second.get("name").asText()); + assertTrue(second.get("concurrency").isNull()); + assertTrue(second.get("worker_concurrency").isNull()); + assertTrue(second.get("rate_limit_max").isNull()); + assertTrue(second.get("rate_limit_period_sec").isNull()); + assertFalse(second.get("priority_enabled").asBoolean()); + assertFalse(second.get("partition_queue").asBoolean()); + assertEquals(1.0, second.get("polling_interval_sec").asDouble(), 0.001); + } + } + + @RetryingTest(3) + public void canListQueuesThrows() throws Exception { + MessageListener listener = new MessageListener(); + testServer.setListener(listener); + + String errorMessage = "canListQueuesThrows error"; + doThrow(new RuntimeException(errorMessage)).when(mockDB).listQueues(); + + try (Conductor conductor = builder.build()) { + conductor.start(); + assertTrue(listener.openLatch.await(5, TimeUnit.SECONDS), "open latch timed out"); + + listener.send(MessageType.LIST_QUEUES, "req-list-queues-err", Map.of()); + assertTrue(listener.messageLatch.await(1, TimeUnit.SECONDS), "message latch timed out"); + + JsonNode json = mapper.readTree(listener.message); + assertEquals("list_queues", json.get("type").asText()); + assertEquals(errorMessage, json.get("error_message").asText()); + assertEquals(0, json.get("output").size()); + } + } + + @RetryingTest(3) + public void canGetQueue() throws Exception { + MessageListener listener = new MessageListener(); + testServer.setListener(listener); + + dev.dbos.transact.workflow.Queue queue = + new dev.dbos.transact.workflow.Queue("my-queue").withConcurrency(3); + when(mockDB.getQueue("my-queue")).thenReturn(Optional.of(queue)); + + try (Conductor conductor = builder.build()) { + conductor.start(); + assertTrue(listener.openLatch.await(5, TimeUnit.SECONDS), "open latch timed out"); + + listener.send(MessageType.GET_QUEUE, "req-get-queue", Map.of("name", "my-queue")); + assertTrue(listener.messageLatch.await(1, TimeUnit.SECONDS), "message latch timed out"); + + verify(mockDB).getQueue("my-queue"); + + JsonNode json = mapper.readTree(listener.message); + assertEquals("get_queue", json.get("type").asText()); + assertEquals("req-get-queue", json.get("request_id").asText()); + assertNull(json.get("error_message")); + + JsonNode output = json.get("output"); + assertNotNull(output); + assertEquals("my-queue", output.get("name").asText()); + assertEquals(3, output.get("concurrency").asInt()); + assertTrue(output.get("worker_concurrency").isNull()); + } + } + + @RetryingTest(3) + public void canGetQueueNotFound() throws Exception { + MessageListener listener = new MessageListener(); + testServer.setListener(listener); + + when(mockDB.getQueue("nonexistent")).thenReturn(Optional.empty()); + + try (Conductor conductor = builder.build()) { + conductor.start(); + assertTrue(listener.openLatch.await(5, TimeUnit.SECONDS), "open latch timed out"); + + listener.send(MessageType.GET_QUEUE, "req-get-queue-404", Map.of("name", "nonexistent")); + assertTrue(listener.messageLatch.await(1, TimeUnit.SECONDS), "message latch timed out"); + + JsonNode json = mapper.readTree(listener.message); + assertEquals("get_queue", json.get("type").asText()); + assertEquals("req-get-queue-404", json.get("request_id").asText()); + assertNull(json.get("error_message")); + assertTrue(!json.has("output") || json.get("output").isNull()); + } + } } diff --git a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java index 742ebc91..1d03c178 100644 --- a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java +++ b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java @@ -1584,7 +1584,7 @@ public void testUpsertQueueInsert() { boolean inserted = sysdb.upsertQueue(queue, true); assertTrue(inserted, "upsertQueue should return true when the row is new"); - var fetched = sysdb.getQueueFromDB("q-insert"); + var fetched = sysdb.getQueue("q-insert"); assertTrue(fetched.isPresent()); var q = fetched.get(); assertEquals("q-insert", q.name()); @@ -1605,7 +1605,7 @@ public void testUpsertQueueUpdateExisting() { boolean inserted = sysdb.upsertQueue(updated, true); assertFalse(inserted, "upsertQueue should return false when the row already existed"); - var fetched = sysdb.getQueueFromDB("q-update").orElseThrow(); + var fetched = sysdb.getQueue("q-update").orElseThrow(); assertEquals(7, fetched.concurrency()); assertEquals(4, fetched.workerConcurrency()); } @@ -1619,14 +1619,14 @@ public void testUpsertQueueNoUpdateExisting() { boolean inserted = sysdb.upsertQueue(attempted, false); assertFalse(inserted, "upsertQueue should return false when the row already existed"); - var fetched = sysdb.getQueueFromDB("q-no-update").orElseThrow(); + var fetched = sysdb.getQueue("q-no-update").orElseThrow(); assertEquals( 3, fetched.concurrency(), "concurrency should be unchanged when updateExisting=false"); } @Test public void testGetQueueFromDBMissing() { - var result = sysdb.getQueueFromDB("does-not-exist"); + var result = sysdb.getQueue("does-not-exist"); assertTrue(result.isEmpty()); } @@ -1636,7 +1636,7 @@ public void testListQueuesFromDB() { sysdb.upsertQueue(new Queue("q-list-b").withConcurrency(2), true); sysdb.upsertQueue(new Queue("q-list-c"), true); - var queues = sysdb.listQueuesFromDB(); + var queues = sysdb.listQueues(); var names = queues.stream().map(Queue::name).toList(); assertTrue(names.contains("q-list-a")); assertTrue(names.contains("q-list-b")); @@ -1646,11 +1646,11 @@ public void testListQueuesFromDB() { @Test public void testDeleteQueue() { sysdb.upsertQueue(new Queue("q-delete").withConcurrency(1), true); - assertTrue(sysdb.getQueueFromDB("q-delete").isPresent()); + assertTrue(sysdb.getQueue("q-delete").isPresent()); boolean deleted = sysdb.deleteQueue("q-delete"); assertTrue(deleted); - assertTrue(sysdb.getQueueFromDB("q-delete").isEmpty()); + assertTrue(sysdb.getQueue("q-delete").isEmpty()); } @Test @@ -1669,7 +1669,7 @@ public void testUpdateQueuePartialConcurrency() { sysdb.updateQueue("q-partial", QueueUpdate.setConcurrency(99)); - var q = sysdb.getQueueFromDB("q-partial").orElseThrow(); + var q = sysdb.getQueue("q-partial").orElseThrow(); assertEquals(99, q.concurrency(), "concurrency should be updated"); assertTrue(q.priorityEnabled(), "priorityEnabled should be unchanged"); assertNotNull(q.rateLimit(), "rateLimit should be unchanged"); @@ -1682,7 +1682,7 @@ public void testUpdateQueueClearConcurrency() { sysdb.updateQueue("q-clear-conc", QueueUpdate.setConcurrency(null)); - var q = sysdb.getQueueFromDB("q-clear-conc").orElseThrow(); + var q = sysdb.getQueue("q-clear-conc").orElseThrow(); assertNull(q.concurrency(), "concurrency should be cleared to null"); } @@ -1694,7 +1694,7 @@ public void testUpdateQueueClearRateLimit() { sysdb.updateQueue("q-clear-rate", QueueUpdate.setRateLimit(null, null)); - var q = sysdb.getQueueFromDB("q-clear-rate").orElseThrow(); + var q = sysdb.getQueue("q-clear-rate").orElseThrow(); assertNull(q.rateLimit(), "rateLimit should be cleared to null"); } @@ -1714,7 +1714,7 @@ public void testUpdateQueueEmpty() { Field.absent()); sysdb.updateQueue("q-empty-update", emptyUpdate); - var q = sysdb.getQueueFromDB("q-empty-update").orElseThrow(); + var q = sysdb.getQueue("q-empty-update").orElseThrow(); assertEquals(5, q.concurrency()); } @@ -1730,7 +1730,7 @@ public void testUpsertQueueRoundTrip() { .withPollingInterval(Duration.ofSeconds(5)); sysdb.upsertQueue(original, true); - var fetched = sysdb.getQueueFromDB("q-roundtrip").orElseThrow(); + var fetched = sysdb.getQueue("q-roundtrip").orElseThrow(); assertEquals(original.name(), fetched.name()); assertEquals(original.concurrency(), fetched.concurrency()); From be06865d71ff2f08c331d02f783ba076035f88bb Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 14:17:09 -0700 Subject: [PATCH 04/23] WIP --- .../src/main/java/dev/dbos/transact/DBOS.java | 12 ++ .../transact/database/SystemDatabase.java | 4 +- .../dbos/transact/database/dao/QueuesDAO.java | 14 +- .../dbos/transact/execution/DBOSExecutor.java | 5 + .../dbos/transact/workflow/QueueOptions.java | 154 ++++++++++++++++++ .../dbos/transact/workflow/QueueUpdate.java | 137 ---------------- .../transact/database/SystemDatabaseTest.java | 12 +- 7 files changed, 189 insertions(+), 149 deletions(-) create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java delete mode 100644 transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 6645266e..06ffb51e 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -11,6 +11,7 @@ import dev.dbos.transact.internal.QueueRegistry; import dev.dbos.transact.internal.WorkflowRegistry; import dev.dbos.transact.migrations.MigrationManager; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; @@ -164,6 +165,17 @@ public void registerQueues(@NonNull Queue... queues) { } } + /** + * Register a database-backed dynamic queue. Must be called after launch. Queue configuration can + * be updated at runtime via {@code DBOS.updateQueue(String, QueueOptions)}. + * + * @param name Queue name + * @param options Initial configuration options + */ + public void registerQueue(@NonNull String name, @NonNull QueueOptions options) { + ensureLaunched("registerQueue").registerDynamicQueue(name, options); + } + /** * Register all workflows and steps in the provided class instance * diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index 85553ba4..7cb23b15 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -22,7 +22,7 @@ import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.NotificationInfo; import dev.dbos.transact.workflow.Queue; -import dev.dbos.transact.workflow.QueueUpdate; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.VersionInfo; @@ -390,7 +390,7 @@ public boolean upsertQueue(Queue queue, boolean updateExisting) { return dbRetry(() -> QueuesDAO.upsertQueue(ctx, queue, updateExisting)); } - public void updateQueue(String name, QueueUpdate update) { + public void updateQueue(String name, QueueOptions update) { dbRetry(() -> QueuesDAO.updateQueue(ctx, name, update)); } diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index 6febc173..d5cf6b6e 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -3,7 +3,7 @@ import dev.dbos.transact.database.DbContext; import dev.dbos.transact.workflow.Field; import dev.dbos.transact.workflow.Queue; -import dev.dbos.transact.workflow.QueueUpdate; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.WorkflowState; import java.sql.Connection; @@ -413,7 +413,7 @@ public static List listQueues(DbContext ctx) throws SQLException { } } - public static void updateQueue(DbContext ctx, String name, QueueUpdate update) + public static void updateQueue(DbContext ctx, String name, QueueOptions update) throws SQLException { if (update.isEmpty()) return; @@ -423,10 +423,10 @@ public static void updateQueue(DbContext ctx, String name, QueueUpdate update) collectField(setClauses, params, "concurrency", update.concurrency()); collectField(setClauses, params, "worker_concurrency", update.workerConcurrency()); collectField(setClauses, params, "rate_limit_max", update.rateLimitMax()); - collectField(setClauses, params, "rate_limit_period_sec", update.rateLimitPeriodSec()); + collectField(setClauses, params, "rate_limit_period_sec", durationToSec(update.rateLimitPeriod())); collectField(setClauses, params, "priority_enabled", update.priorityEnabled()); collectField(setClauses, params, "partition_queue", update.partitionQueue()); - collectField(setClauses, params, "polling_interval_sec", update.pollingIntervalSec()); + collectField(setClauses, params, "polling_interval_sec", durationToSec(update.pollingInterval())); setClauses.add("\"updated_at\" = ?"); params.add(System.currentTimeMillis()); @@ -453,6 +453,12 @@ private static void collectField( } } + private static Field durationToSec(Field field) { + if (!field.isPresent()) return Field.absent(); + Duration d = field.get(); + return Field.of(d != null ? d.toMillis() / 1000.0 : null); + } + public static boolean deleteQueue(DbContext ctx, String name) throws SQLException { final String sql = "DELETE FROM \"%s\".queues WHERE name = ?".formatted(ctx.schema()); diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 92149f72..0dc521c6 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -36,6 +36,7 @@ import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.Timeout; import dev.dbos.transact.workflow.VersionInfo; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowDelay; import dev.dbos.transact.workflow.WorkflowHandle; @@ -390,6 +391,10 @@ public Optional getQueue(String queueName) { return Optional.ofNullable(this.queueMap.get(queueName)); } + public void registerDynamicQueue(String name, QueueOptions options) { + systemDatabase.upsertQueue(options.toQueue(name), true); + } + public void fireAlertHandler(String name, String message, Map metadata) { if (alertHandler != null) { alertHandler.invoke(name, message, metadata); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java new file mode 100644 index 00000000..86212a2d --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java @@ -0,0 +1,154 @@ +package dev.dbos.transact.workflow; + +import java.time.Duration; +import java.util.concurrent.TimeUnit; +import org.jspecify.annotations.Nullable; + +/** + * Configuration options for a DBOS workflow queue. Used for both registration of database-backed + * queues and partial updates to queue configuration. + * + *

When used for registration, absent and null fields both result in the column being null (no + * limit / use default). When used for updates, absent fields are left unchanged in the database + * while null-valued fields clear the column. + */ +public record QueueOptions( + Field concurrency, + Field workerConcurrency, + Field rateLimitMax, + Field rateLimitPeriod, + Field priorityEnabled, + Field partitionQueue, + Field pollingInterval) { + + private static final QueueOptions EMPTY = + new QueueOptions( + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent(), + Field.absent()); + + public boolean isEmpty() { + return !concurrency.isPresent() + && !workerConcurrency.isPresent() + && !rateLimitMax.isPresent() + && !rateLimitPeriod.isPresent() + && !priorityEnabled.isPresent() + && !partitionQueue.isPresent() + && !pollingInterval.isPresent(); + } + + // ── Static factories ────────────────────────────────────────────────────── + + public static QueueOptions setConcurrency(@Nullable Integer value) { + return EMPTY.withConcurrency(Field.of(value)); + } + + public static QueueOptions setWorkerConcurrency(@Nullable Integer value) { + return EMPTY.withWorkerConcurrency(Field.of(value)); + } + + public static QueueOptions setRateLimit(@Nullable Integer max, @Nullable Duration period) { + return EMPTY.withRateLimitMax(Field.of(max)).withRateLimitPeriod(Field.of(period)); + } + + public static QueueOptions setRateLimit(int limit, Duration period) { + return setRateLimit(limit, (Duration) period); + } + + public static QueueOptions setRateLimit(int limit, long period, TimeUnit unit) { + return setRateLimit(limit, Duration.of(period, unit.toChronoUnit())); + } + + public static QueueOptions setPriorityEnabled(boolean value) { + return EMPTY.withPriorityEnabled(Field.of(value)); + } + + public static QueueOptions setPartitionQueue(boolean value) { + return EMPTY.withPartitionQueue(Field.of(value)); + } + + public static QueueOptions setPollingInterval(@Nullable Duration value) { + return EMPTY.withPollingInterval(Field.of(value)); + } + + // ── Builders for chaining ───────────────────────────────────────────────── + + public QueueOptions withConcurrency(Field concurrency) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + public QueueOptions withWorkerConcurrency(Field workerConcurrency) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + public QueueOptions withRateLimitMax(Field rateLimitMax) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + public QueueOptions withRateLimitPeriod(Field rateLimitPeriod) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + public QueueOptions withPriorityEnabled(Field priorityEnabled) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + public QueueOptions withPartitionQueue(Field partitionQueue) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + public QueueOptions withPollingInterval(Field pollingInterval) { + return new QueueOptions( + concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, + priorityEnabled, partitionQueue, pollingInterval); + } + + // ── Conversion for DB registration ─────────────────────────────────────── + + /** + * Converts to a {@link Queue} record for DB upsert. Absent and null fields both produce null + * column values (no limit / use default). + */ + public Queue toQueue(String name) { + Integer concurrencyVal = concurrency.isPresent() ? concurrency.get() : null; + Integer workerConcurrencyVal = workerConcurrency.isPresent() ? workerConcurrency.get() : null; + Boolean priorityEnabledVal = priorityEnabled.isPresent() ? priorityEnabled.get() : null; + Boolean partitionQueueVal = partitionQueue.isPresent() ? partitionQueue.get() : null; + + Queue.RateLimit rateLimit = null; + if (rateLimitMax.isPresent() && rateLimitPeriod.isPresent() + && rateLimitMax.get() != null && rateLimitPeriod.get() != null) { + rateLimit = new Queue.RateLimit(rateLimitMax.get(), rateLimitPeriod.get()); + } + + Duration pollingIntervalVal = Queue.DEFAULT_POLLING_INTERVAL; + if (pollingInterval.isPresent() && pollingInterval.get() != null) { + pollingIntervalVal = pollingInterval.get(); + } + + return new Queue( + name, + concurrencyVal, + workerConcurrencyVal, + Boolean.TRUE.equals(priorityEnabledVal), + Boolean.TRUE.equals(partitionQueueVal), + rateLimit, + pollingIntervalVal); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java deleted file mode 100644 index 5dda0fd8..00000000 --- a/transact/src/main/java/dev/dbos/transact/workflow/QueueUpdate.java +++ /dev/null @@ -1,137 +0,0 @@ -package dev.dbos.transact.workflow; - -import org.jspecify.annotations.Nullable; - -/** Partial update specification for a queue row. Only present fields are written to the DB. */ -public record QueueUpdate( - Field concurrency, - Field workerConcurrency, - Field rateLimitMax, - Field rateLimitPeriodSec, - Field priorityEnabled, - Field partitionQueue, - Field pollingIntervalSec) { - - private static final QueueUpdate EMPTY = - new QueueUpdate( - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent()); - - public boolean isEmpty() { - return !concurrency.isPresent() - && !workerConcurrency.isPresent() - && !rateLimitMax.isPresent() - && !rateLimitPeriodSec.isPresent() - && !priorityEnabled.isPresent() - && !partitionQueue.isPresent() - && !pollingIntervalSec.isPresent(); - } - - public static QueueUpdate setConcurrency(@Nullable Integer value) { - return EMPTY.withConcurrency(Field.of(value)); - } - - public static QueueUpdate setWorkerConcurrency(@Nullable Integer value) { - return EMPTY.withWorkerConcurrency(Field.of(value)); - } - - public static QueueUpdate setRateLimit(@Nullable Integer max, @Nullable Double periodSec) { - return EMPTY.withRateLimitMax(Field.of(max)).withRateLimitPeriodSec(Field.of(periodSec)); - } - - public static QueueUpdate setPriorityEnabled(boolean value) { - return EMPTY.withPriorityEnabled(Field.of(value)); - } - - public static QueueUpdate setPartitionQueue(boolean value) { - return EMPTY.withPartitionQueue(Field.of(value)); - } - - public static QueueUpdate setPollingIntervalSec(@Nullable Double value) { - return EMPTY.withPollingIntervalSec(Field.of(value)); - } - - // --- with* builders for chaining --- - - public QueueUpdate withConcurrency(Field concurrency) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } - - public QueueUpdate withWorkerConcurrency(Field workerConcurrency) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } - - public QueueUpdate withRateLimitMax(Field rateLimitMax) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } - - public QueueUpdate withRateLimitPeriodSec(Field rateLimitPeriodSec) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } - - public QueueUpdate withPriorityEnabled(Field priorityEnabled) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } - - public QueueUpdate withPartitionQueue(Field partitionQueue) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } - - public QueueUpdate withPollingIntervalSec(Field pollingIntervalSec) { - return new QueueUpdate( - concurrency, - workerConcurrency, - rateLimitMax, - rateLimitPeriodSec, - priorityEnabled, - partitionQueue, - pollingIntervalSec); - } -} diff --git a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java index 1d03c178..c2bac96e 100644 --- a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java +++ b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java @@ -25,7 +25,7 @@ import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.GetWorkflowAggregatesInput; import dev.dbos.transact.workflow.Queue; -import dev.dbos.transact.workflow.QueueUpdate; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.VersionInfo; import dev.dbos.transact.workflow.WorkflowDelay; @@ -1597,7 +1597,7 @@ public void testUpsertQueueInsert() { } @Test - public void testUpsertQueueUpdateExisting() { + public void testUpsertQueueOptionsExisting() { var queue = new Queue("q-update").withConcurrency(3); sysdb.upsertQueue(queue, true); @@ -1667,7 +1667,7 @@ public void testUpdateQueuePartialConcurrency() { .withRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS), true); - sysdb.updateQueue("q-partial", QueueUpdate.setConcurrency(99)); + sysdb.updateQueue("q-partial", QueueOptions.setConcurrency(99)); var q = sysdb.getQueue("q-partial").orElseThrow(); assertEquals(99, q.concurrency(), "concurrency should be updated"); @@ -1680,7 +1680,7 @@ public void testUpdateQueuePartialConcurrency() { public void testUpdateQueueClearConcurrency() { sysdb.upsertQueue(new Queue("q-clear-conc").withConcurrency(5), true); - sysdb.updateQueue("q-clear-conc", QueueUpdate.setConcurrency(null)); + sysdb.updateQueue("q-clear-conc", QueueOptions.setConcurrency(null)); var q = sysdb.getQueue("q-clear-conc").orElseThrow(); assertNull(q.concurrency(), "concurrency should be cleared to null"); @@ -1692,7 +1692,7 @@ public void testUpdateQueueClearRateLimit() { new Queue("q-clear-rate").withRateLimit(5, 30, java.util.concurrent.TimeUnit.SECONDS), true); - sysdb.updateQueue("q-clear-rate", QueueUpdate.setRateLimit(null, null)); + sysdb.updateQueue("q-clear-rate", QueueOptions.setRateLimit(null, null)); var q = sysdb.getQueue("q-clear-rate").orElseThrow(); assertNull(q.rateLimit(), "rateLimit should be cleared to null"); @@ -1704,7 +1704,7 @@ public void testUpdateQueueEmpty() { // Empty update should be a no-op (no exception, no change) var emptyUpdate = - new QueueUpdate( + new QueueOptions( Field.absent(), Field.absent(), Field.absent(), From 6cd677f25d4fd2d77dc88c2fc7d3423d7f6ec5cf Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 14:17:24 -0700 Subject: [PATCH 05/23] spotless --- .../src/main/java/dev/dbos/transact/DBOS.java | 2 +- .../dbos/transact/database/dao/QueuesDAO.java | 6 +- .../dbos/transact/execution/DBOSExecutor.java | 2 +- .../dbos/transact/workflow/QueueOptions.java | 70 ++++++++++++++----- 4 files changed, 60 insertions(+), 20 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 06ffb51e..5ba16a55 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -11,10 +11,10 @@ import dev.dbos.transact.internal.QueueRegistry; import dev.dbos.transact.internal.WorkflowRegistry; import dev.dbos.transact.migrations.MigrationManager; -import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Step; diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index d5cf6b6e..8c4fa542 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -423,10 +423,12 @@ public static void updateQueue(DbContext ctx, String name, QueueOptions update) collectField(setClauses, params, "concurrency", update.concurrency()); collectField(setClauses, params, "worker_concurrency", update.workerConcurrency()); collectField(setClauses, params, "rate_limit_max", update.rateLimitMax()); - collectField(setClauses, params, "rate_limit_period_sec", durationToSec(update.rateLimitPeriod())); + collectField( + setClauses, params, "rate_limit_period_sec", durationToSec(update.rateLimitPeriod())); collectField(setClauses, params, "priority_enabled", update.priorityEnabled()); collectField(setClauses, params, "partition_queue", update.partitionQueue()); - collectField(setClauses, params, "polling_interval_sec", durationToSec(update.pollingInterval())); + collectField( + setClauses, params, "polling_interval_sec", durationToSec(update.pollingInterval())); setClauses.add("\"updated_at\" = ?"); params.add(System.currentTimeMillis()); diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 0dc521c6..a0021e22 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -30,13 +30,13 @@ import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.Timeout; import dev.dbos.transact.workflow.VersionInfo; -import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowDelay; import dev.dbos.transact.workflow.WorkflowHandle; diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java index 86212a2d..887cd24a 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java @@ -2,6 +2,7 @@ import java.time.Duration; import java.util.concurrent.TimeUnit; + import org.jspecify.annotations.Nullable; /** @@ -79,44 +80,79 @@ public static QueueOptions setPollingInterval(@Nullable Duration value) { public QueueOptions withConcurrency(Field concurrency) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } public QueueOptions withWorkerConcurrency(Field workerConcurrency) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } public QueueOptions withRateLimitMax(Field rateLimitMax) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } public QueueOptions withRateLimitPeriod(Field rateLimitPeriod) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } public QueueOptions withPriorityEnabled(Field priorityEnabled) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } public QueueOptions withPartitionQueue(Field partitionQueue) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } public QueueOptions withPollingInterval(Field pollingInterval) { return new QueueOptions( - concurrency, workerConcurrency, rateLimitMax, rateLimitPeriod, - priorityEnabled, partitionQueue, pollingInterval); + concurrency, + workerConcurrency, + rateLimitMax, + rateLimitPeriod, + priorityEnabled, + partitionQueue, + pollingInterval); } // ── Conversion for DB registration ─────────────────────────────────────── @@ -132,8 +168,10 @@ public Queue toQueue(String name) { Boolean partitionQueueVal = partitionQueue.isPresent() ? partitionQueue.get() : null; Queue.RateLimit rateLimit = null; - if (rateLimitMax.isPresent() && rateLimitPeriod.isPresent() - && rateLimitMax.get() != null && rateLimitPeriod.get() != null) { + if (rateLimitMax.isPresent() + && rateLimitPeriod.isPresent() + && rateLimitMax.get() != null + && rateLimitPeriod.get() != null) { rateLimit = new Queue.RateLimit(rateLimitMax.get(), rateLimitPeriod.get()); } From 3e118ba97acdcc293afbb9d5a1b35d8c9b508b16 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 14:33:18 -0700 Subject: [PATCH 06/23] wip --- .../src/main/java/dev/dbos/transact/DBOS.java | 15 +++- .../java/dev/dbos/transact/DBOSClient.java | 22 +++++ .../transact/database/SystemDatabase.java | 4 +- .../dbos/transact/database/dao/QueuesDAO.java | 37 +++++++- .../dbos/transact/execution/DBOSExecutor.java | 8 +- .../dbos/transact/workflow/QueueOptions.java | 69 ++++++++------- .../transact/database/SystemDatabaseTest.java | 87 +++++++++---------- 7 files changed, 157 insertions(+), 85 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 5ba16a55..9b5e3604 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -167,13 +167,24 @@ public void registerQueues(@NonNull Queue... queues) { /** * Register a database-backed dynamic queue. Must be called after launch. Queue configuration can - * be updated at runtime via {@code DBOS.updateQueue(String, QueueOptions)}. + * be updated at runtime via {@link #updateQueue(String, QueueOptions)}. * * @param name Queue name * @param options Initial configuration options */ public void registerQueue(@NonNull String name, @NonNull QueueOptions options) { - ensureLaunched("registerQueue").registerDynamicQueue(name, options); + ensureLaunched("registerQueue").registerQueue(name, options); + } + + /** + * Update the configuration of a database-backed dynamic queue. Must be called after launch. Only + * fields that are present in {@code options} are written; absent fields are left unchanged. + * + * @param name Queue name + * @param options Fields to update + */ + public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { + ensureLaunched("updateQueue").updateQueue(name, options); } /** diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 01fdfed2..4f894ebd 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -13,6 +13,7 @@ import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.StepInfo; @@ -1097,4 +1098,25 @@ public void setWorkflowDelay(@NonNull String workflowId, @NonNull Instant delayU Objects.requireNonNull(delayUntil, "delayUntil must not be null")); systemDatabase.setWorkflowDelay(workflowId, wfDelay); } + + /** + * Register a database-backed dynamic queue, or replace its configuration if it already exists. + * + * @param name Queue name + * @param options Configuration options + */ + public void registerQueue(@NonNull String name, @NonNull QueueOptions options) { + systemDatabase.upsertQueue(name, options, true); + } + + /** + * Update the configuration of a database-backed dynamic queue. Only fields that are present in + * {@code options} are written; absent fields are left unchanged. + * + * @param name Queue name + * @param options Fields to update + */ + public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { + systemDatabase.updateQueue(name, options); + } } diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index 7cb23b15..28d011e1 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -386,8 +386,8 @@ public List getQueuePartitions(String queueName) { return dbRetry(() -> QueuesDAO.getQueuePartitions(ctx, queueName)); } - public boolean upsertQueue(Queue queue, boolean updateExisting) { - return dbRetry(() -> QueuesDAO.upsertQueue(ctx, queue, updateExisting)); + public boolean upsertQueue(String name, QueueOptions options, boolean updateExisting) { + return dbRetry(() -> QueuesDAO.upsertQueue(ctx, name, options, updateExisting)); } public void updateQueue(String name, QueueOptions update) { diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index 8c4fa542..9c2a69ba 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -302,8 +302,10 @@ public static List getQueuePartitions(DbContext ctx, String queueName) * Upsert a queue row. Returns true iff a new row was inserted (i.e. the queue did not previously * exist). Returns false if the row already existed, regardless of whether it was updated. */ - public static boolean upsertQueue(DbContext ctx, Queue queue, boolean updateExisting) + public static boolean upsertQueue( + DbContext ctx, String name, QueueOptions options, boolean updateExisting) throws SQLException { + Queue queue = queueFromOptions(name, options); final String conflictClause = updateExisting ? """ @@ -500,6 +502,39 @@ private static Queue queueFromResultSet(ResultSet rs) throws SQLException { pollingInterval); } + private static Queue queueFromOptions(String name, QueueOptions options) { + Integer concurrencyVal = options.concurrency().isPresent() ? options.concurrency().get() : null; + Integer workerConcurrencyVal = + options.workerConcurrency().isPresent() ? options.workerConcurrency().get() : null; + Boolean priorityEnabledVal = + options.priorityEnabled().isPresent() ? options.priorityEnabled().get() : null; + Boolean partitionQueueVal = + options.partitionQueue().isPresent() ? options.partitionQueue().get() : null; + + Queue.RateLimit rateLimit = null; + if (options.rateLimitMax().isPresent() + && options.rateLimitPeriod().isPresent() + && options.rateLimitMax().get() != null + && options.rateLimitPeriod().get() != null) { + rateLimit = + new Queue.RateLimit(options.rateLimitMax().get(), options.rateLimitPeriod().get()); + } + + Duration pollingIntervalVal = Queue.DEFAULT_POLLING_INTERVAL; + if (options.pollingInterval().isPresent() && options.pollingInterval().get() != null) { + pollingIntervalVal = options.pollingInterval().get(); + } + + return new Queue( + name, + concurrencyVal, + workerConcurrencyVal, + Boolean.TRUE.equals(priorityEnabledVal), + Boolean.TRUE.equals(partitionQueueVal), + rateLimit, + pollingIntervalVal); + } + private static void setNullableInt(PreparedStatement stmt, int index, Integer value) throws SQLException { if (value != null) { diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index a0021e22..d50d39f5 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -391,8 +391,12 @@ public Optional getQueue(String queueName) { return Optional.ofNullable(this.queueMap.get(queueName)); } - public void registerDynamicQueue(String name, QueueOptions options) { - systemDatabase.upsertQueue(options.toQueue(name), true); + public void registerQueue(String name, QueueOptions options) { + systemDatabase.upsertQueue(name, options, true); + } + + public void updateQueue(String name, QueueOptions options) { + systemDatabase.updateQueue(name, options); } public void fireAlertHandler(String name, String message, Map metadata) { diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java index 887cd24a..311ecdcd 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java @@ -32,6 +32,10 @@ public record QueueOptions( Field.absent(), Field.absent()); + public static QueueOptions empty() { + return EMPTY; + } + public boolean isEmpty() { return !concurrency.isPresent() && !workerConcurrency.isPresent() @@ -155,38 +159,37 @@ public QueueOptions withPollingInterval(Field pollingInterval) { pollingInterval); } - // ── Conversion for DB registration ─────────────────────────────────────── - - /** - * Converts to a {@link Queue} record for DB upsert. Absent and null fields both produce null - * column values (no limit / use default). - */ - public Queue toQueue(String name) { - Integer concurrencyVal = concurrency.isPresent() ? concurrency.get() : null; - Integer workerConcurrencyVal = workerConcurrency.isPresent() ? workerConcurrency.get() : null; - Boolean priorityEnabledVal = priorityEnabled.isPresent() ? priorityEnabled.get() : null; - Boolean partitionQueueVal = partitionQueue.isPresent() ? partitionQueue.get() : null; - - Queue.RateLimit rateLimit = null; - if (rateLimitMax.isPresent() - && rateLimitPeriod.isPresent() - && rateLimitMax.get() != null - && rateLimitPeriod.get() != null) { - rateLimit = new Queue.RateLimit(rateLimitMax.get(), rateLimitPeriod.get()); - } - - Duration pollingIntervalVal = Queue.DEFAULT_POLLING_INTERVAL; - if (pollingInterval.isPresent() && pollingInterval.get() != null) { - pollingIntervalVal = pollingInterval.get(); - } - - return new Queue( - name, - concurrencyVal, - workerConcurrencyVal, - Boolean.TRUE.equals(priorityEnabledVal), - Boolean.TRUE.equals(partitionQueueVal), - rateLimit, - pollingIntervalVal); + // ── Convenience chaining methods (take raw values, wrap in Field.of) ──── + + public QueueOptions andConcurrency(@Nullable Integer value) { + return withConcurrency(Field.of(value)); + } + + public QueueOptions andWorkerConcurrency(@Nullable Integer value) { + return withWorkerConcurrency(Field.of(value)); + } + + public QueueOptions andRateLimit(@Nullable Integer max, @Nullable Duration period) { + return withRateLimitMax(Field.of(max)).withRateLimitPeriod(Field.of(period)); + } + + public QueueOptions andRateLimit(int max, Duration period) { + return andRateLimit(max, (Duration) period); + } + + public QueueOptions andRateLimit(int max, long period, TimeUnit unit) { + return andRateLimit(max, Duration.of(period, unit.toChronoUnit())); + } + + public QueueOptions andPriorityEnabled(boolean value) { + return withPriorityEnabled(Field.of(value)); + } + + public QueueOptions andPartitionQueue(boolean value) { + return withPartitionQueue(Field.of(value)); + } + + public QueueOptions andPollingInterval(@Nullable Duration value) { + return withPollingInterval(Field.of(value)); } } diff --git a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java index c2bac96e..cd02becc 100644 --- a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java +++ b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java @@ -1574,14 +1574,13 @@ public void testGetAllNotificationsEmpty() throws Exception { @Test public void testUpsertQueueInsert() { - var queue = - new Queue("q-insert") - .withConcurrency(5) - .withWorkerConcurrency(2) - .withPriorityEnabled(true) - .withRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS); - - boolean inserted = sysdb.upsertQueue(queue, true); + var options = + QueueOptions.setConcurrency(5) + .andWorkerConcurrency(2) + .andPriorityEnabled(true) + .andRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS); + + boolean inserted = sysdb.upsertQueue("q-insert", options, true); assertTrue(inserted, "upsertQueue should return true when the row is new"); var fetched = sysdb.getQueue("q-insert"); @@ -1598,11 +1597,10 @@ public void testUpsertQueueInsert() { @Test public void testUpsertQueueOptionsExisting() { - var queue = new Queue("q-update").withConcurrency(3); - sysdb.upsertQueue(queue, true); + sysdb.upsertQueue("q-update", QueueOptions.setConcurrency(3), true); - var updated = new Queue("q-update").withConcurrency(7).withWorkerConcurrency(4); - boolean inserted = sysdb.upsertQueue(updated, true); + boolean inserted = + sysdb.upsertQueue("q-update", QueueOptions.setConcurrency(7).andWorkerConcurrency(4), true); assertFalse(inserted, "upsertQueue should return false when the row already existed"); var fetched = sysdb.getQueue("q-update").orElseThrow(); @@ -1612,11 +1610,9 @@ public void testUpsertQueueOptionsExisting() { @Test public void testUpsertQueueNoUpdateExisting() { - var queue = new Queue("q-no-update").withConcurrency(3); - sysdb.upsertQueue(queue, true); + sysdb.upsertQueue("q-no-update", QueueOptions.setConcurrency(3), true); - var attempted = new Queue("q-no-update").withConcurrency(99); - boolean inserted = sysdb.upsertQueue(attempted, false); + boolean inserted = sysdb.upsertQueue("q-no-update", QueueOptions.setConcurrency(99), false); assertFalse(inserted, "upsertQueue should return false when the row already existed"); var fetched = sysdb.getQueue("q-no-update").orElseThrow(); @@ -1632,9 +1628,9 @@ public void testGetQueueFromDBMissing() { @Test public void testListQueuesFromDB() { - sysdb.upsertQueue(new Queue("q-list-a").withConcurrency(1), true); - sysdb.upsertQueue(new Queue("q-list-b").withConcurrency(2), true); - sysdb.upsertQueue(new Queue("q-list-c"), true); + sysdb.upsertQueue("q-list-a", QueueOptions.setConcurrency(1), true); + sysdb.upsertQueue("q-list-b", QueueOptions.setConcurrency(2), true); + sysdb.upsertQueue("q-list-c", QueueOptions.empty(), true); var queues = sysdb.listQueues(); var names = queues.stream().map(Queue::name).toList(); @@ -1645,7 +1641,7 @@ public void testListQueuesFromDB() { @Test public void testDeleteQueue() { - sysdb.upsertQueue(new Queue("q-delete").withConcurrency(1), true); + sysdb.upsertQueue("q-delete", QueueOptions.setConcurrency(1), true); assertTrue(sysdb.getQueue("q-delete").isPresent()); boolean deleted = sysdb.deleteQueue("q-delete"); @@ -1661,10 +1657,10 @@ public void testDeleteQueueMissing() { @Test public void testUpdateQueuePartialConcurrency() { sysdb.upsertQueue( - new Queue("q-partial") - .withConcurrency(5) - .withPriorityEnabled(true) - .withRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS), + "q-partial", + QueueOptions.setConcurrency(5) + .andPriorityEnabled(true) + .andRateLimit(10, 60, java.util.concurrent.TimeUnit.SECONDS), true); sysdb.updateQueue("q-partial", QueueOptions.setConcurrency(99)); @@ -1678,7 +1674,7 @@ public void testUpdateQueuePartialConcurrency() { @Test public void testUpdateQueueClearConcurrency() { - sysdb.upsertQueue(new Queue("q-clear-conc").withConcurrency(5), true); + sysdb.upsertQueue("q-clear-conc", QueueOptions.setConcurrency(5), true); sysdb.updateQueue("q-clear-conc", QueueOptions.setConcurrency(null)); @@ -1689,7 +1685,8 @@ public void testUpdateQueueClearConcurrency() { @Test public void testUpdateQueueClearRateLimit() { sysdb.upsertQueue( - new Queue("q-clear-rate").withRateLimit(5, 30, java.util.concurrent.TimeUnit.SECONDS), + "q-clear-rate", + QueueOptions.setRateLimit(5, 30, java.util.concurrent.TimeUnit.SECONDS), true); sysdb.updateQueue("q-clear-rate", QueueOptions.setRateLimit(null, null)); @@ -1700,7 +1697,7 @@ public void testUpdateQueueClearRateLimit() { @Test public void testUpdateQueueEmpty() { - sysdb.upsertQueue(new Queue("q-empty-update").withConcurrency(5), true); + sysdb.upsertQueue("q-empty-update", QueueOptions.setConcurrency(5), true); // Empty update should be a no-op (no exception, no change) var emptyUpdate = @@ -1720,25 +1717,25 @@ public void testUpdateQueueEmpty() { @Test public void testUpsertQueueRoundTrip() { - var original = - new Queue("q-roundtrip") - .withConcurrency(8) - .withWorkerConcurrency(4) - .withPriorityEnabled(true) - .withPartitioningEnabled(true) - .withRateLimit(20, 30, java.util.concurrent.TimeUnit.SECONDS) - .withPollingInterval(Duration.ofSeconds(5)); - - sysdb.upsertQueue(original, true); + sysdb.upsertQueue( + "q-roundtrip", + QueueOptions.setConcurrency(8) + .andWorkerConcurrency(4) + .andPriorityEnabled(true) + .andPartitionQueue(true) + .andRateLimit(20, 30, java.util.concurrent.TimeUnit.SECONDS) + .andPollingInterval(Duration.ofSeconds(5)), + true); var fetched = sysdb.getQueue("q-roundtrip").orElseThrow(); - assertEquals(original.name(), fetched.name()); - assertEquals(original.concurrency(), fetched.concurrency()); - assertEquals(original.workerConcurrency(), fetched.workerConcurrency()); - assertEquals(original.priorityEnabled(), fetched.priorityEnabled()); - assertEquals(original.partitioningEnabled(), fetched.partitioningEnabled()); - assertEquals(original.rateLimit().limit(), fetched.rateLimit().limit()); - assertEquals(original.rateLimit().period(), fetched.rateLimit().period()); - assertEquals(original.pollingInterval(), fetched.pollingInterval()); + assertEquals("q-roundtrip", fetched.name()); + assertEquals(8, fetched.concurrency()); + assertEquals(4, fetched.workerConcurrency()); + assertTrue(fetched.priorityEnabled()); + assertTrue(fetched.partitioningEnabled()); + assertNotNull(fetched.rateLimit()); + assertEquals(20, fetched.rateLimit().limit()); + assertEquals(Duration.ofSeconds(30), fetched.rateLimit().period()); + assertEquals(Duration.ofSeconds(5), fetched.pollingInterval()); } } From 1bf58d04f66e32a556fe244669c0246390a83cc0 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 14:44:07 -0700 Subject: [PATCH 07/23] QueueConflictResolution --- .../src/main/java/dev/dbos/transact/DBOS.java | 22 +++++++++++++- .../java/dev/dbos/transact/DBOSClient.java | 29 +++++++++++++++++-- .../dbos/transact/execution/DBOSExecutor.java | 12 ++++++-- .../workflow/QueueConflictResolution.java | 23 +++++++++++++++ 4 files changed, 81 insertions(+), 5 deletions(-) create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/QueueConflictResolution.java diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 9b5e3604..890ee7c0 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -14,6 +14,7 @@ import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueConflictResolution; import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.SerializationStrategy; @@ -169,11 +170,30 @@ public void registerQueues(@NonNull Queue... queues) { * Register a database-backed dynamic queue. Must be called after launch. Queue configuration can * be updated at runtime via {@link #updateQueue(String, QueueOptions)}. * + *

Uses {@link QueueConflictResolution#UPDATE_IF_LATEST_VERSION} by default: the existing + * configuration is overwritten only if this executor is running the latest application version. + * * @param name Queue name * @param options Initial configuration options */ public void registerQueue(@NonNull String name, @NonNull QueueOptions options) { - ensureLaunched("registerQueue").registerQueue(name, options); + ensureLaunched("registerQueue") + .registerQueue(name, options, QueueConflictResolution.UPDATE_IF_LATEST_VERSION); + } + + /** + * Register a database-backed dynamic queue. Must be called after launch. Queue configuration can + * be updated at runtime via {@link #updateQueue(String, QueueOptions)}. + * + * @param name Queue name + * @param options Initial configuration options + * @param onConflict How to handle an existing queue with the same name + */ + public void registerQueue( + @NonNull String name, + @NonNull QueueOptions options, + @NonNull QueueConflictResolution onConflict) { + ensureLaunched("registerQueue").registerQueue(name, options, onConflict); } /** diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 4f894ebd..97661ecd 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -13,6 +13,7 @@ import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; +import dev.dbos.transact.workflow.QueueConflictResolution; import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.SerializationStrategy; @@ -1100,13 +1101,37 @@ public void setWorkflowDelay(@NonNull String workflowId, @NonNull Instant delayU } /** - * Register a database-backed dynamic queue, or replace its configuration if it already exists. + * Register a database-backed dynamic queue. Uses {@link QueueConflictResolution#ALWAYS_UPDATE} by + * default. * * @param name Queue name * @param options Configuration options */ public void registerQueue(@NonNull String name, @NonNull QueueOptions options) { - systemDatabase.upsertQueue(name, options, true); + registerQueue(name, options, QueueConflictResolution.ALWAYS_UPDATE); + } + + /** + * Register a database-backed dynamic queue. + * + *

{@link QueueConflictResolution#UPDATE_IF_LATEST_VERSION} is not supported by {@code + * DBOSClient} because clients are not associated with an application version. Use {@link + * QueueConflictResolution#ALWAYS_UPDATE} or {@link QueueConflictResolution#NEVER_UPDATE}. + * + * @param name Queue name + * @param options Configuration options + * @param onConflict How to handle an existing queue with the same name + */ + public void registerQueue( + @NonNull String name, + @NonNull QueueOptions options, + @NonNull QueueConflictResolution onConflict) { + if (onConflict == QueueConflictResolution.UPDATE_IF_LATEST_VERSION) { + throw new IllegalArgumentException( + "DBOSClient.registerQueue does not support UPDATE_IF_LATEST_VERSION because clients are" + + " not associated with an application version. Use ALWAYS_UPDATE or NEVER_UPDATE."); + } + systemDatabase.upsertQueue(name, options, onConflict == QueueConflictResolution.ALWAYS_UPDATE); } /** diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index d50d39f5..9b9dc25c 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -30,6 +30,7 @@ import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueConflictResolution; import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; import dev.dbos.transact.workflow.SerializationStrategy; @@ -391,8 +392,15 @@ public Optional getQueue(String queueName) { return Optional.ofNullable(this.queueMap.get(queueName)); } - public void registerQueue(String name, QueueOptions options) { - systemDatabase.upsertQueue(name, options, true); + public void registerQueue(String name, QueueOptions options, QueueConflictResolution onConflict) { + boolean updateExisting = + switch (onConflict) { + case ALWAYS_UPDATE -> true; + case NEVER_UPDATE -> false; + case UPDATE_IF_LATEST_VERSION -> + appVersion.equals(systemDatabase.getLatestApplicationVersion().versionName()); + }; + systemDatabase.upsertQueue(name, options, updateExisting); } public void updateQueue(String name, QueueOptions options) { diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueConflictResolution.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueConflictResolution.java new file mode 100644 index 00000000..5aee7d5a --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueConflictResolution.java @@ -0,0 +1,23 @@ +package dev.dbos.transact.workflow; + +/** + * Controls what happens when {@code registerQueue} is called for a queue that already exists in the + * database. + */ +public enum QueueConflictResolution { + /** + * Overwrite the existing queue configuration unconditionally. Default for {@link + * dev.dbos.transact.DBOSClient}. + */ + ALWAYS_UPDATE, + + /** Leave the existing queue configuration unchanged. */ + NEVER_UPDATE, + + /** + * Overwrite the existing queue configuration only if the running application version matches the + * latest application version registered in the database. Default for {@link + * dev.dbos.transact.DBOS}. + */ + UPDATE_IF_LATEST_VERSION, +} From 846445f1dd8e663d666684ca9abf78e05c722996 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 14:54:51 -0700 Subject: [PATCH 08/23] fix tests --- .../dev/dbos/transact/workflow/Queue.java | 25 +++++++++++-------- .../dbos/transact/workflow/QueueOptions.java | 8 ------ 2 files changed, 14 insertions(+), 19 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Queue.java b/transact/src/main/java/dev/dbos/transact/workflow/Queue.java index 7a07ab40..125529cf 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Queue.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Queue.java @@ -4,18 +4,21 @@ import java.util.Objects; import java.util.concurrent.TimeUnit; +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + /** * Property definition for a DBOS workflow queue. Provides options for a name, concurrency and rate * limits, prioritization behavior and partitioned behavior */ public record Queue( - String name, - Integer concurrency, - Integer workerConcurrency, + @NonNull String name, + @Nullable Integer concurrency, + @Nullable Integer workerConcurrency, boolean priorityEnabled, boolean partitioningEnabled, - RateLimit rateLimit, - Duration pollingInterval) { + @Nullable RateLimit rateLimit, + @NonNull Duration pollingInterval) { public static final Duration DEFAULT_POLLING_INTERVAL = Duration.ofSeconds(1); @@ -36,7 +39,7 @@ public static record RateLimit(int limit, Duration period) {} } /** Construct a queue with a given name */ - public Queue(String name) { + public Queue(@NonNull String name) { this(name, null, null, false, false, null, DEFAULT_POLLING_INTERVAL); } @@ -48,7 +51,7 @@ public boolean hasLimiter() { } /** Produces a new Queue with the assigned name. */ - public Queue withName(String name) { + public Queue withName(@NonNull String name) { return new Queue( name, concurrency, @@ -63,7 +66,7 @@ public Queue withName(String name) { * Produces a new Queue with the assigned global concurrency. `null` may be specified to remove * the concurrency limit. */ - public Queue withConcurrency(Integer concurrency) { + public Queue withConcurrency(@Nullable Integer concurrency) { return new Queue( name, concurrency, @@ -78,7 +81,7 @@ public Queue withConcurrency(Integer concurrency) { * Produces a new Queue with the assigned per-worker concurrency. `null` may be specified to * remove the concurrency limit. */ - public Queue withWorkerConcurrency(Integer workerConcurrency) { + public Queue withWorkerConcurrency(@Nullable Integer workerConcurrency) { return new Queue( name, concurrency, @@ -117,7 +120,7 @@ public Queue withPartitioningEnabled(boolean partitioningEnabled) { * Produces a new Queue with the assigned rate limit. `null` may be specified to remove the rate * limit. */ - public Queue withRateLimit(RateLimit rateLimit) { + public Queue withRateLimit(@Nullable RateLimit rateLimit) { return new Queue( name, concurrency, @@ -141,7 +144,7 @@ public Queue withRateLimit(int limit, long period, TimeUnit unit) { } /** Produces a new Queue with the assigned polling interval. */ - public Queue withPollingInterval(Duration pollingInterval) { + public Queue withPollingInterval(@NonNull Duration pollingInterval) { return new Queue( name, concurrency, diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java index 311ecdcd..ea2ffeaf 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java @@ -60,10 +60,6 @@ public static QueueOptions setRateLimit(@Nullable Integer max, @Nullable Duratio return EMPTY.withRateLimitMax(Field.of(max)).withRateLimitPeriod(Field.of(period)); } - public static QueueOptions setRateLimit(int limit, Duration period) { - return setRateLimit(limit, (Duration) period); - } - public static QueueOptions setRateLimit(int limit, long period, TimeUnit unit) { return setRateLimit(limit, Duration.of(period, unit.toChronoUnit())); } @@ -173,10 +169,6 @@ public QueueOptions andRateLimit(@Nullable Integer max, @Nullable Duration perio return withRateLimitMax(Field.of(max)).withRateLimitPeriod(Field.of(period)); } - public QueueOptions andRateLimit(int max, Duration period) { - return andRateLimit(max, (Duration) period); - } - public QueueOptions andRateLimit(int max, long period, TimeUnit unit) { return andRateLimit(max, Duration.of(period, unit.toChronoUnit())); } From c31c45243d8a31115e6b4e98590c5167fc31bebb Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 14:54:59 -0700 Subject: [PATCH 09/23] use queue polling interval --- .../src/main/java/dev/dbos/transact/execution/QueueService.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 71eee21d..0597abcc 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -95,7 +95,7 @@ private void startQueueListeners(Collection queues, Set listenQue var task = new Runnable() { final Queue queue = _queue; - Duration pollingInterval = Duration.ofSeconds(1); + Duration pollingInterval = queue.pollingInterval(); public void schedule() { var randomSleepFactor = 0.95 + ThreadLocalRandom.current().nextDouble(0.1); From 755a9d96f5ea7b02b5821b9c35bfae790f205db2 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 15:34:45 -0700 Subject: [PATCH 10/23] inline pollWorkflowSchedulesImpl --- .../transact/execution/SchedulerService.java | 282 +++++++++--------- 1 file changed, 140 insertions(+), 142 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java b/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java index f290ac75..13fd1a86 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java @@ -85,170 +85,168 @@ public void unpause() { private void pollWorkflowSchedules() { try { - pollWorkflowSchedulesImpl(); - } catch (Exception e) { - // Catch all exceptions to prevent scheduleAtFixedRate from permanently suppressing future - // poll invocations. A transient DB failure should not permanently disable the scheduler. - logger.error("pollWorkflowSchedules failed", e); - } - } - - private void pollWorkflowSchedulesImpl() { - // if execServiceRef is null, the scheduler service was shut down so don't poll schedules - if (execServiceRef.get() == null) { - return; - } - - var schedules = dbosExecutor.listSchedules(null, null, null); - if (logger.isDebugEnabled()) { - logger.debug("pollWorkflowSchedules found {} schedules", schedules.size()); - for (var s : schedules) { - logger.debug( - " schedule: {} workflow: {} cron: {}", s.scheduleName(), s.workflowName(), s.cron()); + // if execServiceRef is null, the scheduler service was shut down so don't poll schedules + if (execServiceRef.get() == null) { + return; } - } - // shut down any scheduled future that isn't in the list of current schedules - var currentIds = schedules.stream().map(WorkflowSchedule::id).collect(Collectors.toSet()); - for (var key : workflowScheduleFutures.keySet()) { - if (!currentIds.contains(key)) { - cancelWorkflowSchedule(key); + var schedules = dbosExecutor.listSchedules(null, null, null); + if (logger.isDebugEnabled()) { + logger.debug("pollWorkflowSchedules found {} schedules", schedules.size()); + for (var s : schedules) { + logger.debug( + " schedule: {} workflow: {} cron: {}", s.scheduleName(), s.workflowName(), s.cron()); + } } - } - for (var schedule : schedules) { - var scheduleRunning = workflowScheduleFutures.containsKey(schedule.id()); - if (!schedule.isActive()) { - // if the schedule is no longer active but we still have a scheduled future for it, cancel - // it - if (scheduleRunning) { - cancelWorkflowSchedule(schedule.id()); - } - } else if (!scheduleRunning) { - // if the schedule is active but we don't yet have a scheduled future for it, schedule it - // now - var optRegWf = - dbosExecutor.getRegisteredWorkflow(schedule.workflowName(), schedule.className(), ""); - if (optRegWf.isEmpty()) { - logger.error( - "Workflow schedule {} has missing workflow function {}", - schedule.scheduleName(), - RegisteredWorkflow.fullyQualifiedName(schedule.workflowName(), schedule.className())); - continue; + // shut down any scheduled future that isn't in the list of current schedules + var currentIds = schedules.stream().map(WorkflowSchedule::id).collect(Collectors.toSet()); + for (var key : workflowScheduleFutures.keySet()) { + if (!currentIds.contains(key)) { + cancelWorkflowSchedule(key); } + } - var regWorkflow = optRegWf.orElseThrow(); - if (!Arrays.equals(regWorkflow.workflowMethod().getParameterTypes(), EXPECTED_PARAMETERS)) { - logger.error( - "Workflow schedule {} workflow {} has invalid signature, signature must be (Instant, Object)", - schedule.scheduleName(), - regWorkflow.fullyQualifiedName()); - continue; - } + for (var schedule : schedules) { + var scheduleRunning = workflowScheduleFutures.containsKey(schedule.id()); + if (!schedule.isActive()) { + // if the schedule is no longer active but we still have a scheduled future for it, cancel + // it + if (scheduleRunning) { + cancelWorkflowSchedule(schedule.id()); + } + } else if (!scheduleRunning) { + // if the schedule is active but we don't yet have a scheduled future for it, schedule it + // now + var optRegWf = + dbosExecutor.getRegisteredWorkflow(schedule.workflowName(), schedule.className(), ""); + if (optRegWf.isEmpty()) { + logger.error( + "Workflow schedule {} has missing workflow function {}", + schedule.scheduleName(), + RegisteredWorkflow.fullyQualifiedName( + schedule.workflowName(), schedule.className())); + continue; + } - final String queueName = - Objects.requireNonNullElse(schedule.queueName(), Constants.DBOS_INTERNAL_QUEUE); - if (dbosExecutor.getQueue(queueName).isEmpty()) { - logger.error( - "Workflow schedule {} has invalid queue {}", schedule.scheduleName(), queueName); - continue; - } + var regWorkflow = optRegWf.orElseThrow(); + if (!Arrays.equals( + regWorkflow.workflowMethod().getParameterTypes(), EXPECTED_PARAMETERS)) { + logger.error( + "Workflow schedule {} workflow {} has invalid signature, signature must be (Instant, Object)", + schedule.scheduleName(), + regWorkflow.fullyQualifiedName()); + continue; + } - Cron cron; - try { - cron = CRON_PARSER.parse(schedule.cron()).validate(); - } catch (Exception e) { - logger.error( - "Workflow schedule {} has invalid cron expression {}", - schedule.scheduleName(), - schedule.cron(), - e); - continue; - } + final String queueName = + Objects.requireNonNullElse(schedule.queueName(), Constants.DBOS_INTERNAL_QUEUE); + if (dbosExecutor.getQueue(queueName).isEmpty()) { + logger.error( + "Workflow schedule {} has invalid queue {}", schedule.scheduleName(), queueName); + continue; + } - if (schedule.automaticBackfill() - && schedule.lastFiredAt() != null - && schedule.lastFiredAt().isBefore(Instant.now())) { - dbosExecutor.backfillSchedule( - schedule.scheduleName(), schedule.lastFiredAt(), Instant.now()); - } + Cron cron; + try { + cron = CRON_PARSER.parse(schedule.cron()).validate(); + } catch (Exception e) { + logger.error( + "Workflow schedule {} has invalid cron expression {}", + schedule.scheduleName(), + schedule.cron(), + e); + continue; + } - var task = - new Runnable() { + if (schedule.automaticBackfill() + && schedule.lastFiredAt() != null + && schedule.lastFiredAt().isBefore(Instant.now())) { + dbosExecutor.backfillSchedule( + schedule.scheduleName(), schedule.lastFiredAt(), Instant.now()); + } - final ZoneId timeZone = - Objects.requireNonNullElseGet( - schedule.cronTimezone(), () -> ZoneId.systemDefault()); - final WorkflowSchedule wfSchedule = schedule; - final ExecutionTime executionTime = ExecutionTime.forCron(cron); + var task = + new Runnable() { - ZonedDateTime nextTime = ZonedDateTime.now(timeZone); + final ZoneId timeZone = + Objects.requireNonNullElseGet( + schedule.cronTimezone(), () -> ZoneId.systemDefault()); + final WorkflowSchedule wfSchedule = schedule; + final ExecutionTime executionTime = ExecutionTime.forCron(cron); - public void schedule() { - executionTime - .nextExecution(nextTime) - .ifPresent( - cronTime -> { - this.nextTime = cronTime.truncatedTo(ChronoUnit.SECONDS); - var prevFuture = - workflowScheduleFutures.put( - wfSchedule.id(), scheduleTask(this.nextTime, this)); - // prevFuture should be null or a scheduled task that already fired. - // cancel it anyway just to be sure - if (prevFuture != null) { - if (!prevFuture.isDone()) { - logger.debug( - "Previous scheduled task for {} has not yet completed", - wfSchedule.scheduleName()); - } - prevFuture.cancel(false); - } - }); - } + ZonedDateTime nextTime = ZonedDateTime.now(timeZone); - @Override - public void run() { - // if execServiceRef is null, the scheduler service was shut down so don't start the - // workflow or schedule the next execution - if (execServiceRef.get() == null) { - return; + public void schedule() { + executionTime + .nextExecution(nextTime) + .ifPresent( + cronTime -> { + this.nextTime = cronTime.truncatedTo(ChronoUnit.SECONDS); + var prevFuture = + workflowScheduleFutures.put( + wfSchedule.id(), scheduleTask(this.nextTime, this)); + // prevFuture should be null or a scheduled task that already fired. + // cancel it anyway just to be sure + if (prevFuture != null) { + if (!prevFuture.isDone()) { + logger.debug( + "Previous scheduled task for {} has not yet completed", + wfSchedule.scheduleName()); + } + prevFuture.cancel(false); + } + }); } - try { - if (paused.get()) { + @Override + public void run() { + // if execServiceRef is null, the scheduler service was shut down so don't start + // the workflow or schedule the next execution + if (execServiceRef.get() == null) { + return; + } + + try { + if (paused.get()) { + logger.debug( + "Skipping scheduled workflow {} schedule {} because scheduler is paused", + regWorkflow.fullyQualifiedName(), + wfSchedule.scheduleName()); + return; + } + var args = new Object[] {nextTime.toInstant(), wfSchedule.context()}; + var workflowId = + "sched-%s-%s" + .formatted(wfSchedule.scheduleName(), nextTime.toOffsetDateTime()); logger.debug( - "Skipping scheduled workflow {} schedule {} because scheduler is paused", + "Queuing scheduled workflow {} schedule {} workflowId {}", regWorkflow.fullyQualifiedName(), - wfSchedule.scheduleName()); - return; + wfSchedule.scheduleName(), + workflowId); + var appVersion = dbosExecutor.getLatestApplicationVersion().versionName(); + var options = + new StartWorkflowOptions(workflowId) + .withQueue(queueName) + .withAppVersion(appVersion); + dbosExecutor.startRegisteredWorkflow(regWorkflow, args, options); + systemDatabase.updateScheduleLastFiredAt( + wfSchedule.scheduleName(), nextTime.toInstant()); + } catch (Exception e) { + logger.error("Scheduled task {} exception", schedule.scheduleName(), e); + } finally { + schedule(); } - var args = new Object[] {nextTime.toInstant(), wfSchedule.context()}; - var workflowId = - "sched-%s-%s" - .formatted(wfSchedule.scheduleName(), nextTime.toOffsetDateTime()); - logger.debug( - "Queuing scheduled workflow {} schedule {} workflowId {}", - regWorkflow.fullyQualifiedName(), - wfSchedule.scheduleName(), - workflowId); - var appVersion = dbosExecutor.getLatestApplicationVersion().versionName(); - var options = - new StartWorkflowOptions(workflowId) - .withQueue(queueName) - .withAppVersion(appVersion); - dbosExecutor.startRegisteredWorkflow(regWorkflow, args, options); - systemDatabase.updateScheduleLastFiredAt( - wfSchedule.scheduleName(), nextTime.toInstant()); - } catch (Exception e) { - logger.error("Scheduled task {} exception", schedule.scheduleName(), e); - } finally { - schedule(); } - } - }; + }; - task.schedule(); + task.schedule(); + } } + } catch (Exception e) { + // Catch all exceptions to prevent scheduleAtFixedRate from permanently suppressing future + // poll invocations. A transient DB failure should not permanently disable the scheduler. + logger.error("pollWorkflowSchedules failed", e); } } From ed188a5d5d4c4c1f184e260ab0d00adbe858120d Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 16:22:49 -0700 Subject: [PATCH 11/23] pollDynamicQueues + tests --- .../src/main/java/dev/dbos/transact/DBOS.java | 29 +++ .../java/dev/dbos/transact/DBOSClient.java | 30 +++ .../dbos/transact/conductor/Conductor.java | 2 +- .../transact/database/SystemDatabase.java | 4 +- .../dbos/transact/database/dao/QueuesDAO.java | 2 +- .../dbos/transact/execution/DBOSExecutor.java | 16 +- .../dbos/transact/execution/QueueService.java | 131 ++++++++++++- .../transact/conductor/ConductorTest.java | 6 +- .../transact/database/SystemDatabaseTest.java | 22 +-- .../transact/queue/DynamicQueuesTest.java | 172 ++++++++++++++++++ 10 files changed, 393 insertions(+), 21 deletions(-) create mode 100644 transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 890ee7c0..df386ccd 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -207,6 +207,35 @@ public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { ensureLaunched("updateQueue").updateQueue(name, options); } + /** + * Retrieve a database-backed dynamic queue by name. Must be called after launch. + * + * @param name Queue name + * @return the queue if it exists in the database, or empty + */ + public @NonNull Optional findQueue(@NonNull String name) { + return ensureLaunched("findQueue").findQueue(name); + } + + /** + * Delete a database-backed dynamic queue. Must be called after launch. + * + * @param name Queue name + * @return true if the queue was deleted, false if it did not exist + */ + public boolean deleteQueue(@NonNull String name) { + return ensureLaunched("deleteQueue").deleteQueue(name); + } + + /** + * List all database-backed dynamic queues. Must be called after launch. + * + * @return list of all queues currently registered in the database + */ + public @NonNull List listQueues() { + return ensureLaunched("listQueues").listQueues(); + } + /** * Register all workflows and steps in the provided class instance * diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 97661ecd..a011b206 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -13,6 +13,7 @@ import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; +import dev.dbos.transact.workflow.Queue; import dev.dbos.transact.workflow.QueueConflictResolution; import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.ScheduleStatus; @@ -1144,4 +1145,33 @@ public void registerQueue( public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { systemDatabase.updateQueue(name, options); } + + /** + * Retrieve a database-backed dynamic queue by name. + * + * @param name Queue name + * @return the queue if it exists in the database, or empty + */ + public @NonNull Optional findQueue(@NonNull String name) { + return systemDatabase.findQueue(name); + } + + /** + * List all database-backed dynamic queues. + * + * @return list of all queues currently registered in the database + */ + public @NonNull List listQueues() { + return systemDatabase.listQueues(); + } + + /** + * Delete a database-backed dynamic queue. + * + * @param name Queue name + * @return true if the queue was deleted, false if it did not exist + */ + public boolean deleteQueue(@NonNull String name) { + return systemDatabase.deleteQueue(name); + } } diff --git a/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java b/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java index 021f3fbb..f59c1b1a 100644 --- a/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java +++ b/transact/src/main/java/dev/dbos/transact/conductor/Conductor.java @@ -1387,7 +1387,7 @@ static CompletableFuture handleGetQueue(Conductor conductor, BaseM () -> { GetQueueRequest request = (GetQueueRequest) message; try { - var queue = conductor.systemDatabase.getQueue(request.name); + var queue = conductor.systemDatabase.findQueue(request.name); return new GetQueueResponse(request, queue.map(QueueOutput::from).orElse(null)); } catch (Exception e) { logger.error("Exception encountered when getting queue {}", request.name, e); diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index 28d011e1..937794ae 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -394,8 +394,8 @@ public void updateQueue(String name, QueueOptions update) { dbRetry(() -> QueuesDAO.updateQueue(ctx, name, update)); } - public Optional getQueue(String name) { - return dbRetry(() -> QueuesDAO.getQueue(ctx, name)); + public Optional findQueue(String name) { + return dbRetry(() -> QueuesDAO.findQueue(ctx, name)); } public List listQueues() { diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index 9c2a69ba..19a2ec49 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -370,7 +370,7 @@ ON CONFLICT (name) DO UPDATE SET } } - public static Optional getQueue(DbContext ctx, String name) throws SQLException { + public static Optional findQueue(DbContext ctx, String name) throws SQLException { final String sql = """ SELECT name, concurrency, worker_concurrency, diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 9b9dc25c..973b276c 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -407,6 +407,19 @@ public void updateQueue(String name, QueueOptions options) { systemDatabase.updateQueue(name, options); } + public Optional findQueue(String name) { + return systemDatabase.findQueue(name); + } + + public boolean deleteQueue(String name) { + return systemDatabase.deleteQueue(name); + } + + public List listQueues() { + return systemDatabase.listQueues(); + } + + public void fireAlertHandler(String name, String message, Map metadata) { if (alertHandler != null) { alertHandler.invoke(name, message, metadata); @@ -1419,6 +1432,7 @@ private void validateWorkflow(String workflowName, String className, String inst private void validateQueue(String queueName) { if (queueName != null) { getQueue(queueName) + .or(() -> systemDatabase.findQueue(queueName)) .orElseThrow( () -> new IllegalStateException("Queue %s is not registered".formatted(queueName))); } @@ -1431,7 +1445,7 @@ private void validateQueue(String queueName, String queuePartitionKey) { "DBOS internal queue is not a partitioned queue, but a partition key was provided"); } } else { - var queue = this.getQueue(queueName); + var queue = getQueue(queueName).or(() -> systemDatabase.findQueue(queueName)); if (queue.isPresent()) { if (queue.get().partitioningEnabled() && queuePartitionKey == null) { throw new IllegalArgumentException( diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 0597abcc..5ee95660 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -8,12 +8,14 @@ import java.util.Collection; import java.util.Objects; import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ThreadLocalRandom; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -27,6 +29,7 @@ public class QueueService implements AutoCloseable { private final SystemDatabase systemDatabase; private final DBOSExecutor dbosExecutor; + private Set listenQueues; private double speedup = 1.0; public QueueService(DBOSExecutor dbosExecutor, SystemDatabase systemDatabase) { @@ -51,7 +54,8 @@ public void start(Collection queues, Set listenQueues) { var procCount = Runtime.getRuntime().availableProcessors(); var scheduler = Executors.newScheduledThreadPool(procCount); if (this.execServiceRef.compareAndSet(null, scheduler)) { - startQueueListeners(queues, listenQueues); + this.listenQueues = listenQueues; + startQueueListeners(queues); } } } @@ -69,7 +73,7 @@ public boolean isStopped() { return this.execServiceRef.get() == null; } - private void startQueueListeners(Collection queues, Set listenQueues) { + private void startQueueListeners(Collection queues) { logger.debug("startQueueListeners"); final var executorId = dbosExecutor.executorId(); @@ -80,6 +84,7 @@ private void startQueueListeners(Collection queues, Set listenQue var execService = execServiceRef.get(); if (execService != null) { execService.scheduleAtFixedRate(this::transitionDelayedWorkflows, 1, 1, TimeUnit.SECONDS); + execService.scheduleAtFixedRate(this::pollDynamicQueues, 0, DB_QUEUE_SUPERVISOR_INTERVAL_SEC, TimeUnit.SECONDS); } for (var _queue : queues) { @@ -170,6 +175,128 @@ public void run() { } } + // ── DB-backed (dynamic) queue support ──────────────────────────────────── + + private static final long DB_QUEUE_SUPERVISOR_INTERVAL_SEC = 5; + private static final Duration DB_MIN_POLLING_INTERVAL = Duration.ofSeconds(1); + private static final Duration DB_MAX_POLLING_INTERVAL = Duration.ofSeconds(120); + + private final Set dbListeningQueues = ConcurrentHashMap.newKeySet(); + + private void pollDynamicQueues() { + try { + if (execServiceRef.get() == null) return; + + var dbQueues = systemDatabase.listQueues(); + var dbQueueNames = dbQueues.stream().map(Queue::name).collect(Collectors.toSet()); + + // Stop listeners for queues that have been deleted from the DB. + dbListeningQueues.removeIf(name -> !dbQueueNames.contains(name)); + + for (var queue : dbQueues) { + startDynamicQueueListenerIfNeeded(queue); + } + } catch (Exception e) { + logger.error("pollDynamicQueues failed", e); + } + } + + private void startDynamicQueueListenerIfNeeded(Queue queue) { + var listening = + queue.name().equals(Constants.DBOS_INTERNAL_QUEUE) + || listenQueues.isEmpty() + || listenQueues.contains(queue.name()); + if (!listening) return; + + if (!dbListeningQueues.add(queue.name())) return; + + var execService = execServiceRef.get(); + if (execService == null) return; + + final var executorId = dbosExecutor.executorId(); + final var appVersion = dbosExecutor.appVersion(); + + var task = + new Runnable() { + Queue queue; + Duration pollingInterval; + + public void schedule() { + var randomSleepFactor = 0.95 + ThreadLocalRandom.current().nextDouble(0.1); + var delayMs = (long) (randomSleepFactor * pollingInterval.toMillis() * speedup); + var svc = execServiceRef.get(); + if (svc != null) { + svc.schedule(this, delayMs, TimeUnit.MILLISECONDS); + } + } + + private void processPartition(String partition) { + var partitionLog = Objects.requireNonNullElse(partition, ""); + if (!paused.get()) { + var workflowIds = + systemDatabase.getAndStartQueuedWorkflows( + queue, executorId, appVersion, partition); + if (!workflowIds.isEmpty()) { + logger.debug( + "Retrieved {} workflows from {} partition of queue {}", + workflowIds.size(), + partitionLog, + queue.name()); + } + for (var workflowId : workflowIds) { + logger.debug( + "Starting workflow {} from {} partition of queue {}", + workflowId, + partitionLog, + queue.name()); + dbosExecutor.executeWorkflowById(workflowId, false, true); + } + } + } + + @Override + public void run() { + if (execServiceRef.get() == null) return; + if (!dbListeningQueues.contains(queue.name())) return; + + // Reload config from DB so live updates (concurrency, rate limits, etc.) take effect. + queue = systemDatabase.findQueue(queue.name()).orElse(queue); + + try { + if (queue.partitioningEnabled()) { + var partitions = systemDatabase.getQueuePartitions(queue.name()); + for (var partition : partitions) { + processPartition(partition); + } + } else { + processPartition(null); + } + + pollingInterval = Duration.ofMillis((long) (pollingInterval.toMillis() * 0.9)); + pollingInterval = + pollingInterval.compareTo(DB_MIN_POLLING_INTERVAL) >= 0 + ? pollingInterval + : DB_MIN_POLLING_INTERVAL; + } catch (Exception e) { + logger.error("Error executing queued workflow(s) for queue {}", queue.name(), e); + pollingInterval = pollingInterval.multipliedBy(2); + pollingInterval = + pollingInterval.compareTo(DB_MAX_POLLING_INTERVAL) <= 0 + ? pollingInterval + : DB_MAX_POLLING_INTERVAL; + } finally { + this.schedule(); + } + } + }; + + task.queue = queue; + task.pollingInterval = queue.pollingInterval(); + task.schedule(); + } + + // ── Shared helpers ──────────────────────────────────────────────────────── + private void transitionDelayedWorkflows() { if (!paused.get()) { try { diff --git a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java index dcba59db..b660f94a 100644 --- a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java +++ b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java @@ -3354,7 +3354,7 @@ public void canGetQueue() throws Exception { dev.dbos.transact.workflow.Queue queue = new dev.dbos.transact.workflow.Queue("my-queue").withConcurrency(3); - when(mockDB.getQueue("my-queue")).thenReturn(Optional.of(queue)); + when(mockDB.findQueue("my-queue")).thenReturn(Optional.of(queue)); try (Conductor conductor = builder.build()) { conductor.start(); @@ -3363,7 +3363,7 @@ public void canGetQueue() throws Exception { listener.send(MessageType.GET_QUEUE, "req-get-queue", Map.of("name", "my-queue")); assertTrue(listener.messageLatch.await(1, TimeUnit.SECONDS), "message latch timed out"); - verify(mockDB).getQueue("my-queue"); + verify(mockDB).findQueue("my-queue"); JsonNode json = mapper.readTree(listener.message); assertEquals("get_queue", json.get("type").asText()); @@ -3383,7 +3383,7 @@ public void canGetQueueNotFound() throws Exception { MessageListener listener = new MessageListener(); testServer.setListener(listener); - when(mockDB.getQueue("nonexistent")).thenReturn(Optional.empty()); + when(mockDB.findQueue("nonexistent")).thenReturn(Optional.empty()); try (Conductor conductor = builder.build()) { conductor.start(); diff --git a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java index cd02becc..9539658f 100644 --- a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java +++ b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java @@ -1583,7 +1583,7 @@ public void testUpsertQueueInsert() { boolean inserted = sysdb.upsertQueue("q-insert", options, true); assertTrue(inserted, "upsertQueue should return true when the row is new"); - var fetched = sysdb.getQueue("q-insert"); + var fetched = sysdb.findQueue("q-insert"); assertTrue(fetched.isPresent()); var q = fetched.get(); assertEquals("q-insert", q.name()); @@ -1603,7 +1603,7 @@ public void testUpsertQueueOptionsExisting() { sysdb.upsertQueue("q-update", QueueOptions.setConcurrency(7).andWorkerConcurrency(4), true); assertFalse(inserted, "upsertQueue should return false when the row already existed"); - var fetched = sysdb.getQueue("q-update").orElseThrow(); + var fetched = sysdb.findQueue("q-update").orElseThrow(); assertEquals(7, fetched.concurrency()); assertEquals(4, fetched.workerConcurrency()); } @@ -1615,14 +1615,14 @@ public void testUpsertQueueNoUpdateExisting() { boolean inserted = sysdb.upsertQueue("q-no-update", QueueOptions.setConcurrency(99), false); assertFalse(inserted, "upsertQueue should return false when the row already existed"); - var fetched = sysdb.getQueue("q-no-update").orElseThrow(); + var fetched = sysdb.findQueue("q-no-update").orElseThrow(); assertEquals( 3, fetched.concurrency(), "concurrency should be unchanged when updateExisting=false"); } @Test public void testGetQueueFromDBMissing() { - var result = sysdb.getQueue("does-not-exist"); + var result = sysdb.findQueue("does-not-exist"); assertTrue(result.isEmpty()); } @@ -1642,11 +1642,11 @@ public void testListQueuesFromDB() { @Test public void testDeleteQueue() { sysdb.upsertQueue("q-delete", QueueOptions.setConcurrency(1), true); - assertTrue(sysdb.getQueue("q-delete").isPresent()); + assertTrue(sysdb.findQueue("q-delete").isPresent()); boolean deleted = sysdb.deleteQueue("q-delete"); assertTrue(deleted); - assertTrue(sysdb.getQueue("q-delete").isEmpty()); + assertTrue(sysdb.findQueue("q-delete").isEmpty()); } @Test @@ -1665,7 +1665,7 @@ public void testUpdateQueuePartialConcurrency() { sysdb.updateQueue("q-partial", QueueOptions.setConcurrency(99)); - var q = sysdb.getQueue("q-partial").orElseThrow(); + var q = sysdb.findQueue("q-partial").orElseThrow(); assertEquals(99, q.concurrency(), "concurrency should be updated"); assertTrue(q.priorityEnabled(), "priorityEnabled should be unchanged"); assertNotNull(q.rateLimit(), "rateLimit should be unchanged"); @@ -1678,7 +1678,7 @@ public void testUpdateQueueClearConcurrency() { sysdb.updateQueue("q-clear-conc", QueueOptions.setConcurrency(null)); - var q = sysdb.getQueue("q-clear-conc").orElseThrow(); + var q = sysdb.findQueue("q-clear-conc").orElseThrow(); assertNull(q.concurrency(), "concurrency should be cleared to null"); } @@ -1691,7 +1691,7 @@ public void testUpdateQueueClearRateLimit() { sysdb.updateQueue("q-clear-rate", QueueOptions.setRateLimit(null, null)); - var q = sysdb.getQueue("q-clear-rate").orElseThrow(); + var q = sysdb.findQueue("q-clear-rate").orElseThrow(); assertNull(q.rateLimit(), "rateLimit should be cleared to null"); } @@ -1711,7 +1711,7 @@ public void testUpdateQueueEmpty() { Field.absent()); sysdb.updateQueue("q-empty-update", emptyUpdate); - var q = sysdb.getQueue("q-empty-update").orElseThrow(); + var q = sysdb.findQueue("q-empty-update").orElseThrow(); assertEquals(5, q.concurrency()); } @@ -1726,7 +1726,7 @@ public void testUpsertQueueRoundTrip() { .andRateLimit(20, 30, java.util.concurrent.TimeUnit.SECONDS) .andPollingInterval(Duration.ofSeconds(5)), true); - var fetched = sysdb.getQueue("q-roundtrip").orElseThrow(); + var fetched = sysdb.findQueue("q-roundtrip").orElseThrow(); assertEquals("q-roundtrip", fetched.name()); assertEquals(8, fetched.concurrency()); diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java new file mode 100644 index 00000000..98ffdb59 --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -0,0 +1,172 @@ +package dev.dbos.transact.queue; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.dbos.transact.DBOS; +import dev.dbos.transact.DBOSTestAccess; +import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.utils.PgContainer; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueConflictResolution; +import dev.dbos.transact.workflow.QueueOptions; +import dev.dbos.transact.workflow.WorkflowState; + +import java.time.Duration; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class DynamicQueuesTest { + + @AutoClose final PgContainer pgContainer = new PgContainer(); + + DBOSConfig dbosConfig; + @AutoClose DBOS dbos; + @AutoClose HikariDataSource dataSource; + + @BeforeEach + void beforeEach() { + dbosConfig = pgContainer.dbosConfig(); + dbos = new DBOS(dbosConfig); + dataSource = pgContainer.dataSource(); + } + + @Test + public void testDynamicQueueWorkflowExecution() throws Exception { + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + + // Register a dynamic queue after launch — this writes to DB. + dbos.registerQueue("dynQueue", QueueOptions.empty()); + + // The supervisor polls every 5s; wait for it to discover and start a listener. + var handle = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("hello"), + new StartWorkflowOptions().withQueue("dynQueue")); + + assertEquals("hellohello", handle.getResult()); + assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); + } + + @Test + public void testDynamicQueueConcurrency() throws Exception { + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + + dbos.registerQueue("concQ", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + + for (int i = 0; i < 3; i++) { + String id = "dynwf" + i; + String input = "v" + i; + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow(input), + new StartWorkflowOptions(id).withQueue("concQ")); + } + + for (int i = 0; i < 3; i++) { + var handle = dbos.retrieveWorkflow("dynwf" + i); + assertEquals("v" + i + "v" + i, handle.getResult()); + assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); + } + } + + @Test + public void testListQueues() throws Exception { + dbos.launch(); + + dbos.registerQueue("q-list-1", QueueOptions.setConcurrency(1)); + dbos.registerQueue("q-list-2", QueueOptions.setConcurrency(2)); + dbos.registerQueue("q-list-3", QueueOptions.empty()); + + var queues = dbos.listQueues(); + var names = queues.stream().map(Queue::name).toList(); + assertTrue(names.contains("q-list-1")); + assertTrue(names.contains("q-list-2")); + assertTrue(names.contains("q-list-3")); + assertEquals(3, names.size()); + } + + @Test + public void testDeleteQueue() throws Exception { + dbos.launch(); + + dbos.registerQueue("q-del", QueueOptions.setConcurrency(1)); + assertTrue(dbos.listQueues().stream().anyMatch(q -> q.name().equals("q-del"))); + + boolean deleted = dbos.deleteQueue("q-del"); + assertTrue(deleted); + assertFalse(dbos.listQueues().stream().anyMatch(q -> q.name().equals("q-del"))); + + // deleting a non-existent queue returns false + assertFalse(dbos.deleteQueue("q-never-existed")); + } + + @Test + public void testUpdateQueue() throws Exception { + dbos.launch(); + + dbos.registerQueue("q-update", QueueOptions.setConcurrency(5)); + + var before = + dbos.listQueues().stream().filter(x -> x.name().equals("q-update")).findFirst().orElseThrow(); + assertEquals(5, before.concurrency()); + + dbos.updateQueue("q-update", QueueOptions.setConcurrency(10)); + + var after = + dbos.listQueues().stream().filter(x -> x.name().equals("q-update")).findFirst().orElseThrow(); + assertEquals(10, after.concurrency()); + } + + @Test + public void testRegisterQueueNeverUpdate() throws Exception { + dbos.launch(); + + dbos.registerQueue("q-conflict", QueueOptions.setConcurrency(5)); + + // NEVER_UPDATE: second call should not overwrite + dbos.registerQueue("q-conflict", QueueOptions.setConcurrency(99), QueueConflictResolution.NEVER_UPDATE); + + var q = + dbos.listQueues().stream().filter(x -> x.name().equals("q-conflict")).findFirst().orElseThrow(); + assertEquals(5, q.concurrency()); + } + + @Test + public void testRegisterQueueAlwaysUpdate() throws Exception { + dbos.launch(); + + dbos.registerQueue("q-always", QueueOptions.setConcurrency(5)); + + // ALWAYS_UPDATE: second call should overwrite + dbos.registerQueue("q-always", QueueOptions.setConcurrency(99), QueueConflictResolution.ALWAYS_UPDATE); + + var q = + dbos.listQueues().stream().filter(x -> x.name().equals("q-always")).findFirst().orElseThrow(); + assertEquals(99, q.concurrency()); + } + + @Test + public void testDynamicQueuePollingInterval() throws Exception { + dbos.launch(); + + var interval = Duration.ofSeconds(3); + dbos.registerQueue("q-poll", QueueOptions.setPollingInterval(interval)); + + var q = + dbos.listQueues().stream().filter(x -> x.name().equals("q-poll")).findFirst().orElseThrow(); + assertEquals(interval, q.pollingInterval()); + } +} From 5021923b6d2022abc2e23d32050f1854e7471a73 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 16:23:00 -0700 Subject: [PATCH 12/23] spotless --- .../dbos/transact/execution/DBOSExecutor.java | 1 - .../dbos/transact/execution/QueueService.java | 5 ++-- .../transact/queue/DynamicQueuesTest.java | 29 ++++++++++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 973b276c..44e659e4 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -419,7 +419,6 @@ public List listQueues() { return systemDatabase.listQueues(); } - public void fireAlertHandler(String name, String message, Map metadata) { if (alertHandler != null) { alertHandler.invoke(name, message, metadata); diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 5ee95660..8094da92 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -29,7 +29,7 @@ public class QueueService implements AutoCloseable { private final SystemDatabase systemDatabase; private final DBOSExecutor dbosExecutor; - private Set listenQueues; + private Set listenQueues; private double speedup = 1.0; public QueueService(DBOSExecutor dbosExecutor, SystemDatabase systemDatabase) { @@ -84,7 +84,8 @@ private void startQueueListeners(Collection queues) { var execService = execServiceRef.get(); if (execService != null) { execService.scheduleAtFixedRate(this::transitionDelayedWorkflows, 1, 1, TimeUnit.SECONDS); - execService.scheduleAtFixedRate(this::pollDynamicQueues, 0, DB_QUEUE_SUPERVISOR_INTERVAL_SEC, TimeUnit.SECONDS); + execService.scheduleAtFixedRate( + this::pollDynamicQueues, 0, DB_QUEUE_SUPERVISOR_INTERVAL_SEC, TimeUnit.SECONDS); } for (var _queue : queues) { diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index 98ffdb59..bf95a0f2 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -71,8 +71,7 @@ public void testDynamicQueueConcurrency() throws Exception { String id = "dynwf" + i; String input = "v" + i; dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow(input), - new StartWorkflowOptions(id).withQueue("concQ")); + () -> serviceQ.simpleQWorkflow(input), new StartWorkflowOptions(id).withQueue("concQ")); } for (int i = 0; i < 3; i++) { @@ -120,13 +119,19 @@ public void testUpdateQueue() throws Exception { dbos.registerQueue("q-update", QueueOptions.setConcurrency(5)); var before = - dbos.listQueues().stream().filter(x -> x.name().equals("q-update")).findFirst().orElseThrow(); + dbos.listQueues().stream() + .filter(x -> x.name().equals("q-update")) + .findFirst() + .orElseThrow(); assertEquals(5, before.concurrency()); dbos.updateQueue("q-update", QueueOptions.setConcurrency(10)); var after = - dbos.listQueues().stream().filter(x -> x.name().equals("q-update")).findFirst().orElseThrow(); + dbos.listQueues().stream() + .filter(x -> x.name().equals("q-update")) + .findFirst() + .orElseThrow(); assertEquals(10, after.concurrency()); } @@ -137,10 +142,14 @@ public void testRegisterQueueNeverUpdate() throws Exception { dbos.registerQueue("q-conflict", QueueOptions.setConcurrency(5)); // NEVER_UPDATE: second call should not overwrite - dbos.registerQueue("q-conflict", QueueOptions.setConcurrency(99), QueueConflictResolution.NEVER_UPDATE); + dbos.registerQueue( + "q-conflict", QueueOptions.setConcurrency(99), QueueConflictResolution.NEVER_UPDATE); var q = - dbos.listQueues().stream().filter(x -> x.name().equals("q-conflict")).findFirst().orElseThrow(); + dbos.listQueues().stream() + .filter(x -> x.name().equals("q-conflict")) + .findFirst() + .orElseThrow(); assertEquals(5, q.concurrency()); } @@ -151,10 +160,14 @@ public void testRegisterQueueAlwaysUpdate() throws Exception { dbos.registerQueue("q-always", QueueOptions.setConcurrency(5)); // ALWAYS_UPDATE: second call should overwrite - dbos.registerQueue("q-always", QueueOptions.setConcurrency(99), QueueConflictResolution.ALWAYS_UPDATE); + dbos.registerQueue( + "q-always", QueueOptions.setConcurrency(99), QueueConflictResolution.ALWAYS_UPDATE); var q = - dbos.listQueues().stream().filter(x -> x.name().equals("q-always")).findFirst().orElseThrow(); + dbos.listQueues().stream() + .filter(x -> x.name().equals("q-always")) + .findFirst() + .orElseThrow(); assertEquals(99, q.concurrency()); } From 1c4d92722059054e42d731305859ef858c468aa5 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 16:36:14 -0700 Subject: [PATCH 13/23] cleanup --- .../transact/database/SystemDatabase.java | 4 ++ .../dbos/transact/execution/QueueService.java | 41 +++++++++++-------- .../transact/queue/DynamicQueuesTest.java | 10 +++++ 3 files changed, 38 insertions(+), 17 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index 937794ae..e6f575c4 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -387,6 +387,10 @@ public List getQueuePartitions(String queueName) { } public boolean upsertQueue(String name, QueueOptions options, boolean updateExisting) { + if (Constants.DBOS_INTERNAL_QUEUE.equals(name)) { + throw new IllegalArgumentException( + String.format("%s is a reserved queue name", Constants.DBOS_INTERNAL_QUEUE)); + } return dbRetry(() -> QueuesDAO.upsertQueue(ctx, name, options, updateExisting)); } diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 8094da92..4d76cf15 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -49,13 +49,16 @@ public void unpause() { paused.set(false); } - public void start(Collection queues, Set listenQueues) { + public void start(Collection staticQueues, Set listenQueues) { if (this.execServiceRef.get() == null) { var procCount = Runtime.getRuntime().availableProcessors(); var scheduler = Executors.newScheduledThreadPool(procCount); if (this.execServiceRef.compareAndSet(null, scheduler)) { this.listenQueues = listenQueues; - startQueueListeners(queues); + scheduler.scheduleAtFixedRate(this::transitionDelayedWorkflows, 1, 1, TimeUnit.SECONDS); + scheduler.scheduleAtFixedRate( + this::pollDynamicQueues, 0, DB_QUEUE_SUPERVISOR_INTERVAL_SEC, TimeUnit.SECONDS); + startStaticQueueListeners(staticQueues); } } } @@ -73,34 +76,27 @@ public boolean isStopped() { return this.execServiceRef.get() == null; } - private void startQueueListeners(Collection queues) { - logger.debug("startQueueListeners"); + private void startStaticQueueListeners(Collection staticQueues) { + logger.debug("startStaticQueueListeners"); final var executorId = dbosExecutor.executorId(); final var appVersion = dbosExecutor.appVersion(); final Duration minPollingInterval = Duration.ofSeconds(1); final Duration maxPollingInterval = Duration.ofSeconds(120); - var execService = execServiceRef.get(); - if (execService != null) { - execService.scheduleAtFixedRate(this::transitionDelayedWorkflows, 1, 1, TimeUnit.SECONDS); - execService.scheduleAtFixedRate( - this::pollDynamicQueues, 0, DB_QUEUE_SUPERVISOR_INTERVAL_SEC, TimeUnit.SECONDS); - } - - for (var _queue : queues) { + for (var staticQueue : staticQueues) { var listening = - _queue.name().equals(Constants.DBOS_INTERNAL_QUEUE) + staticQueue.name().equals(Constants.DBOS_INTERNAL_QUEUE) || listenQueues.isEmpty() - || listenQueues.contains(_queue.name()); + || listenQueues.contains(staticQueue.name()); if (!listening) { continue; } var task = new Runnable() { - final Queue queue = _queue; + final Queue queue = staticQueue; Duration pollingInterval = queue.pollingInterval(); public void schedule() { @@ -189,9 +185,20 @@ private void pollDynamicQueues() { if (execServiceRef.get() == null) return; var dbQueues = systemDatabase.listQueues(); - var dbQueueNames = dbQueues.stream().map(Queue::name).collect(Collectors.toSet()); + if (logger.isDebugEnabled()) { + logger.debug("pollDynamicQueues found {} queues", dbQueues.size()); + for (var q : dbQueues) { + logger.debug( + " queue: {} concurrency: {} pollingInterval: {}", + q.name(), + q.concurrency(), + q.pollingInterval()); + } + } - // Stop listeners for queues that have been deleted from the DB. + // Remove listeners for queues that have been deleted from the DB. The listener threads will + // automatically stop on the next poll when they fail to find their queue in dbListeningQueues + var dbQueueNames = dbQueues.stream().map(Queue::name).collect(Collectors.toSet()); dbListeningQueues.removeIf(name -> !dbQueueNames.contains(name)); for (var queue : dbQueues) { diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index bf95a0f2..6b5d23b2 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.dbos.transact.DBOS; @@ -182,4 +183,13 @@ public void testDynamicQueuePollingInterval() throws Exception { dbos.listQueues().stream().filter(x -> x.name().equals("q-poll")).findFirst().orElseThrow(); assertEquals(interval, q.pollingInterval()); } + + @Test + public void testRegisterInternalQueueThrows() throws Exception { + dbos.launch(); + + assertThrows( + IllegalArgumentException.class, + () -> dbos.registerQueue("_dbos_internal_queue", QueueOptions.empty())); + } } From fe9658ff5afe64902b0ec2ad5828685ef165faea Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 16:53:53 -0700 Subject: [PATCH 14/23] spotless --- .../dbos/transact/execution/QueueService.java | 291 ++++++------------ 1 file changed, 100 insertions(+), 191 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 4d76cf15..91d447f0 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -23,9 +23,13 @@ public class QueueService implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(QueueService.class); + private static final Duration MIN_POLLING_INTERVAL = Duration.ofSeconds(1); + private static final Duration MAX_POLLING_INTERVAL = Duration.ofSeconds(120); + private static final long DB_QUEUE_SUPERVISOR_INTERVAL_SEC = 5; private final AtomicReference execServiceRef = new AtomicReference<>(); private final AtomicBoolean paused = new AtomicBoolean(false); + private final Set dbListeningQueues = ConcurrentHashMap.newKeySet(); private final SystemDatabase systemDatabase; private final DBOSExecutor dbosExecutor; @@ -58,7 +62,9 @@ public void start(Collection staticQueues, Set listenQueues) { scheduler.scheduleAtFixedRate(this::transitionDelayedWorkflows, 1, 1, TimeUnit.SECONDS); scheduler.scheduleAtFixedRate( this::pollDynamicQueues, 0, DB_QUEUE_SUPERVISOR_INTERVAL_SEC, TimeUnit.SECONDS); - startStaticQueueListeners(staticQueues); + for (var queue : staticQueues) { + startQueueListenerIfNeeded(queue, false); + } } } } @@ -76,109 +82,22 @@ public boolean isStopped() { return this.execServiceRef.get() == null; } - private void startStaticQueueListeners(Collection staticQueues) { - logger.debug("startStaticQueueListeners"); - - final var executorId = dbosExecutor.executorId(); - final var appVersion = dbosExecutor.appVersion(); - final Duration minPollingInterval = Duration.ofSeconds(1); - final Duration maxPollingInterval = Duration.ofSeconds(120); - - for (var staticQueue : staticQueues) { - - var listening = - staticQueue.name().equals(Constants.DBOS_INTERNAL_QUEUE) - || listenQueues.isEmpty() - || listenQueues.contains(staticQueue.name()); - if (!listening) { - continue; - } - - var task = - new Runnable() { - final Queue queue = staticQueue; - Duration pollingInterval = queue.pollingInterval(); - - public void schedule() { - var randomSleepFactor = 0.95 + ThreadLocalRandom.current().nextDouble(0.1); - var delayMs = (long) (randomSleepFactor * pollingInterval.toMillis() * speedup); - var execService = execServiceRef.get(); - if (execService != null) { - execService.schedule(this, delayMs, TimeUnit.MILLISECONDS); - } - } - - private void processPartition(String partition) { - var partitionLog = Objects.requireNonNullElse(partition, ""); - if (!paused.get()) { - var workflowIds = - systemDatabase.getAndStartQueuedWorkflows( - queue, executorId, appVersion, partition); - if (workflowIds.size() > 0) { - logger.debug( - "Retrieved {} workflows from {} partition of queue {}", - workflowIds.size(), - partitionLog, - queue.name()); - } - for (var workflowId : workflowIds) { - logger.debug( - "Starting workflow {} from {} partition of queue {}", - workflowId, - partitionLog, - queue.name()); - dbosExecutor.executeWorkflowById(workflowId, false, true); - } - } - } - - @Override - public void run() { - // if scheduler service isn't running, the queue service was stopped so don't start - // the workflow or schedule the next execution - if (execServiceRef.get() == null) { - return; - } - - try { - if (queue.partitioningEnabled()) { - var partitions = systemDatabase.getQueuePartitions(queue.name()); - for (var partition : partitions) { - processPartition(partition); - } - } else { - processPartition(null); - } - - pollingInterval = Duration.ofMillis((long) (pollingInterval.toMillis() * 0.9)); - pollingInterval = - pollingInterval.compareTo(minPollingInterval) >= 0 - ? pollingInterval - : minPollingInterval; - } catch (Exception e) { - logger.error("Error executing queued workflow(s) for queue {}", queue.name(), e); - pollingInterval = pollingInterval.multipliedBy(2); - pollingInterval = - pollingInterval.compareTo(maxPollingInterval) <= 0 - ? pollingInterval - : maxPollingInterval; - } finally { - this.schedule(); - } - } - }; - - task.schedule(); - } + private boolean isListening(String queueName) { + return queueName.equals(Constants.DBOS_INTERNAL_QUEUE) + || listenQueues.isEmpty() + || listenQueues.contains(queueName); } - // ── DB-backed (dynamic) queue support ──────────────────────────────────── + private void startQueueListenerIfNeeded(Queue queue, boolean dynamic) { + if (!isListening(queue.name())) return; + if (dynamic && !dbListeningQueues.add(queue.name())) return; + if (execServiceRef.get() == null) return; - private static final long DB_QUEUE_SUPERVISOR_INTERVAL_SEC = 5; - private static final Duration DB_MIN_POLLING_INTERVAL = Duration.ofSeconds(1); - private static final Duration DB_MAX_POLLING_INTERVAL = Duration.ofSeconds(120); + new QueueListenerTask(queue, dynamic) + .schedule(); // executor holds the reference via the scheduled future + } - private final Set dbListeningQueues = ConcurrentHashMap.newKeySet(); + // ── Dynamic queue supervisor ────────────────────────────────────────────── private void pollDynamicQueues() { try { @@ -196,111 +115,101 @@ private void pollDynamicQueues() { } } - // Remove listeners for queues that have been deleted from the DB. The listener threads will - // automatically stop on the next poll when they fail to find their queue in dbListeningQueues + // Remove listeners for queues deleted from DB; listener tasks self-terminate when they + // next fire and find their name absent from dbListeningQueues. var dbQueueNames = dbQueues.stream().map(Queue::name).collect(Collectors.toSet()); dbListeningQueues.removeIf(name -> !dbQueueNames.contains(name)); for (var queue : dbQueues) { - startDynamicQueueListenerIfNeeded(queue); + startQueueListenerIfNeeded(queue, true); } } catch (Exception e) { logger.error("pollDynamicQueues failed", e); } } - private void startDynamicQueueListenerIfNeeded(Queue queue) { - var listening = - queue.name().equals(Constants.DBOS_INTERNAL_QUEUE) - || listenQueues.isEmpty() - || listenQueues.contains(queue.name()); - if (!listening) return; - - if (!dbListeningQueues.add(queue.name())) return; - - var execService = execServiceRef.get(); - if (execService == null) return; - - final var executorId = dbosExecutor.executorId(); - final var appVersion = dbosExecutor.appVersion(); - - var task = - new Runnable() { - Queue queue; - Duration pollingInterval; - - public void schedule() { - var randomSleepFactor = 0.95 + ThreadLocalRandom.current().nextDouble(0.1); - var delayMs = (long) (randomSleepFactor * pollingInterval.toMillis() * speedup); - var svc = execServiceRef.get(); - if (svc != null) { - svc.schedule(this, delayMs, TimeUnit.MILLISECONDS); - } - } + // ── Queue listener task ─────────────────────────────────────────────────── - private void processPartition(String partition) { - var partitionLog = Objects.requireNonNullElse(partition, ""); - if (!paused.get()) { - var workflowIds = - systemDatabase.getAndStartQueuedWorkflows( - queue, executorId, appVersion, partition); - if (!workflowIds.isEmpty()) { - logger.debug( - "Retrieved {} workflows from {} partition of queue {}", - workflowIds.size(), - partitionLog, - queue.name()); - } - for (var workflowId : workflowIds) { - logger.debug( - "Starting workflow {} from {} partition of queue {}", - workflowId, - partitionLog, - queue.name()); - dbosExecutor.executeWorkflowById(workflowId, false, true); - } - } - } + private class QueueListenerTask implements Runnable { + + Queue queue; + Duration pollingInterval; + final boolean dynamic; + final String executorId = dbosExecutor.executorId(); + final String appVersion = dbosExecutor.appVersion(); + + QueueListenerTask(Queue queue, boolean dynamic) { + this.queue = queue; + this.pollingInterval = queue.pollingInterval(); + this.dynamic = dynamic; + } - @Override - public void run() { - if (execServiceRef.get() == null) return; - if (!dbListeningQueues.contains(queue.name())) return; - - // Reload config from DB so live updates (concurrency, rate limits, etc.) take effect. - queue = systemDatabase.findQueue(queue.name()).orElse(queue); - - try { - if (queue.partitioningEnabled()) { - var partitions = systemDatabase.getQueuePartitions(queue.name()); - for (var partition : partitions) { - processPartition(partition); - } - } else { - processPartition(null); - } - - pollingInterval = Duration.ofMillis((long) (pollingInterval.toMillis() * 0.9)); - pollingInterval = - pollingInterval.compareTo(DB_MIN_POLLING_INTERVAL) >= 0 - ? pollingInterval - : DB_MIN_POLLING_INTERVAL; - } catch (Exception e) { - logger.error("Error executing queued workflow(s) for queue {}", queue.name(), e); - pollingInterval = pollingInterval.multipliedBy(2); - pollingInterval = - pollingInterval.compareTo(DB_MAX_POLLING_INTERVAL) <= 0 - ? pollingInterval - : DB_MAX_POLLING_INTERVAL; - } finally { - this.schedule(); - } + void schedule() { + var randomSleepFactor = 0.95 + ThreadLocalRandom.current().nextDouble(0.1); + var delayMs = (long) (randomSleepFactor * pollingInterval.toMillis() * speedup); + var svc = execServiceRef.get(); + if (svc != null) { + svc.schedule(this, delayMs, TimeUnit.MILLISECONDS); + } + } + + private void processPartition(String partition) { + var partitionLog = Objects.requireNonNullElse(partition, ""); + if (!paused.get()) { + var workflowIds = + systemDatabase.getAndStartQueuedWorkflows(queue, executorId, appVersion, partition); + if (!workflowIds.isEmpty()) { + logger.debug( + "Retrieved {} workflows from {} partition of queue {}", + workflowIds.size(), + partitionLog, + queue.name()); + } + for (var workflowId : workflowIds) { + logger.debug( + "Starting workflow {} from {} partition of queue {}", + workflowId, + partitionLog, + queue.name()); + dbosExecutor.executeWorkflowById(workflowId, false, true); + } + } + } + + @Override + public void run() { + if (execServiceRef.get() == null) return; + if (dynamic && !dbListeningQueues.contains(queue.name())) return; + if (dynamic) { + queue = systemDatabase.findQueue(queue.name()).orElse(queue); + } + + try { + if (queue.partitioningEnabled()) { + var partitions = systemDatabase.getQueuePartitions(queue.name()); + for (var partition : partitions) { + processPartition(partition); } - }; + } else { + processPartition(null); + } - task.queue = queue; - task.pollingInterval = queue.pollingInterval(); - task.schedule(); + pollingInterval = Duration.ofMillis((long) (pollingInterval.toMillis() * 0.9)); + pollingInterval = + pollingInterval.compareTo(MIN_POLLING_INTERVAL) >= 0 + ? pollingInterval + : MIN_POLLING_INTERVAL; + } catch (Exception e) { + logger.error("Error executing queued workflow(s) for queue {}", queue.name(), e); + pollingInterval = pollingInterval.multipliedBy(2); + pollingInterval = + pollingInterval.compareTo(MAX_POLLING_INTERVAL) <= 0 + ? pollingInterval + : MAX_POLLING_INTERVAL; + } finally { + this.schedule(); + } + } } // ── Shared helpers ──────────────────────────────────────────────────────── From 74d5f2a40b6a2df9475d7579c547e1c2c42486b5 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 17:10:22 -0700 Subject: [PATCH 15/23] moar cleanup --- .../src/main/java/dev/dbos/transact/DBOS.java | 14 +++++------ .../dev/dbos/transact/admin/AdminServer.java | 2 +- .../dbos/transact/execution/DBOSExecutor.java | 23 +++++++++++-------- .../dbos/transact/execution/QueueService.java | 9 +++++++- .../transact/execution/SchedulerService.java | 2 +- .../dbos/transact/admin/AdminServerTest.java | 4 ++-- 6 files changed, 33 insertions(+), 21 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index df386ccd..e00c5dcd 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -178,7 +178,7 @@ public void registerQueues(@NonNull Queue... queues) { */ public void registerQueue(@NonNull String name, @NonNull QueueOptions options) { ensureLaunched("registerQueue") - .registerQueue(name, options, QueueConflictResolution.UPDATE_IF_LATEST_VERSION); + .registerDynamicQueue(name, options, QueueConflictResolution.UPDATE_IF_LATEST_VERSION); } /** @@ -193,7 +193,7 @@ public void registerQueue( @NonNull String name, @NonNull QueueOptions options, @NonNull QueueConflictResolution onConflict) { - ensureLaunched("registerQueue").registerQueue(name, options, onConflict); + ensureLaunched("registerQueue").registerDynamicQueue(name, options, onConflict); } /** @@ -204,7 +204,7 @@ public void registerQueue( * @param options Fields to update */ public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { - ensureLaunched("updateQueue").updateQueue(name, options); + ensureLaunched("updateQueue").updateDynamicQueue(name, options); } /** @@ -214,7 +214,7 @@ public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { * @return the queue if it exists in the database, or empty */ public @NonNull Optional findQueue(@NonNull String name) { - return ensureLaunched("findQueue").findQueue(name); + return ensureLaunched("findQueue").findDynamicQueue(name); } /** @@ -224,7 +224,7 @@ public void updateQueue(@NonNull String name, @NonNull QueueOptions options) { * @return true if the queue was deleted, false if it did not exist */ public boolean deleteQueue(@NonNull String name) { - return ensureLaunched("deleteQueue").deleteQueue(name); + return ensureLaunched("deleteQueue").deleteDynamicQueue(name); } /** @@ -233,7 +233,7 @@ public boolean deleteQueue(@NonNull String name) { * @return list of all queues currently registered in the database */ public @NonNull List listQueues() { - return ensureLaunched("listQueues").listQueues(); + return ensureLaunched("listQueues").listDynamicQueues(); } /** @@ -383,7 +383,7 @@ private DBOSExecutor ensureLaunched(String caller) { * @return Optional containing the queue definition for given `queueName`, or empty if not found */ public @NonNull Optional getQueue(@NonNull String queueName) { - return ensureLaunched("getQueue").getQueue(queueName); + return ensureLaunched("getQueue").findStaticQueue(queueName); } /** diff --git a/transact/src/main/java/dev/dbos/transact/admin/AdminServer.java b/transact/src/main/java/dev/dbos/transact/admin/AdminServer.java index 1c1ebb29..2819c92a 100644 --- a/transact/src/main/java/dev/dbos/transact/admin/AdminServer.java +++ b/transact/src/main/java/dev/dbos/transact/admin/AdminServer.java @@ -140,7 +140,7 @@ private void deactivate(HttpExchange exchange) throws IOException { } private void workflowQueuesMetadata(HttpExchange exchange) throws IOException { - var queues = dbosExecutor.getQueues(); + var queues = dbosExecutor.getStaticQueues(); sendMappedJson(exchange, 200, queues); } diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 44e659e4..9085877c 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -384,15 +384,20 @@ public Optional getRegisteredWorkflow( return Optional.ofNullable(this.workflowMap.get(fqName)); } - public Collection getQueues() { + public Optional findQueue(String queueName) { + return findStaticQueue(queueName).or(() -> systemDatabase.findQueue(queueName)); + } + + public Collection getStaticQueues() { return this.queueMap.values(); } - public Optional getQueue(String queueName) { + public Optional findStaticQueue(String queueName) { return Optional.ofNullable(this.queueMap.get(queueName)); } - public void registerQueue(String name, QueueOptions options, QueueConflictResolution onConflict) { + public void registerDynamicQueue( + String name, QueueOptions options, QueueConflictResolution onConflict) { boolean updateExisting = switch (onConflict) { case ALWAYS_UPDATE -> true; @@ -403,19 +408,19 @@ public void registerQueue(String name, QueueOptions options, QueueConflictResolu systemDatabase.upsertQueue(name, options, updateExisting); } - public void updateQueue(String name, QueueOptions options) { + public void updateDynamicQueue(String name, QueueOptions options) { systemDatabase.updateQueue(name, options); } - public Optional findQueue(String name) { + public Optional findDynamicQueue(String name) { return systemDatabase.findQueue(name); } - public boolean deleteQueue(String name) { + public boolean deleteDynamicQueue(String name) { return systemDatabase.deleteQueue(name); } - public List listQueues() { + public List listDynamicQueues() { return systemDatabase.listQueues(); } @@ -1430,7 +1435,7 @@ private void validateWorkflow(String workflowName, String className, String inst private void validateQueue(String queueName) { if (queueName != null) { - getQueue(queueName) + findStaticQueue(queueName) .or(() -> systemDatabase.findQueue(queueName)) .orElseThrow( () -> new IllegalStateException("Queue %s is not registered".formatted(queueName))); @@ -1444,7 +1449,7 @@ private void validateQueue(String queueName, String queuePartitionKey) { "DBOS internal queue is not a partitioned queue, but a partition key was provided"); } } else { - var queue = getQueue(queueName).or(() -> systemDatabase.findQueue(queueName)); + var queue = findStaticQueue(queueName).or(() -> systemDatabase.findQueue(queueName)); if (queue.isPresent()) { if (queue.get().partitioningEnabled() && queuePartitionKey == null) { throw new IllegalArgumentException( diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 91d447f0..2d602fe4 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -25,7 +25,7 @@ public class QueueService implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(QueueService.class); private static final Duration MIN_POLLING_INTERVAL = Duration.ofSeconds(1); private static final Duration MAX_POLLING_INTERVAL = Duration.ofSeconds(120); - private static final long DB_QUEUE_SUPERVISOR_INTERVAL_SEC = 5; + private static final long DB_QUEUE_SUPERVISOR_INTERVAL_SEC = 1; private final AtomicReference execServiceRef = new AtomicReference<>(); private final AtomicBoolean paused = new AtomicBoolean(false); @@ -121,6 +121,13 @@ private void pollDynamicQueues() { dbListeningQueues.removeIf(name -> !dbQueueNames.contains(name)); for (var queue : dbQueues) { + if (dbosExecutor.findStaticQueue(queue.name()).isPresent()) { + logger.warn( + "Database-backed queue {} has the same name as a static queue; " + + "the static queue's configuration is being used and the database-backed queue is ignored.", + queue.name()); + continue; + } startQueueListenerIfNeeded(queue, true); } } catch (Exception e) { diff --git a/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java b/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java index 13fd1a86..1461551b 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/SchedulerService.java @@ -141,7 +141,7 @@ private void pollWorkflowSchedules() { final String queueName = Objects.requireNonNullElse(schedule.queueName(), Constants.DBOS_INTERNAL_QUEUE); - if (dbosExecutor.getQueue(queueName).isEmpty()) { + if (dbosExecutor.findQueue(queueName).isEmpty()) { logger.error( "Workflow schedule {} has invalid queue {}", schedule.scheduleName(), queueName); continue; diff --git a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java index 6c8421de..9c8d916d 100644 --- a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java +++ b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java @@ -107,7 +107,7 @@ public void ensurePostJsonNotJson() throws IOException { @Test public void exceptionCatching500() throws IOException { var exception = new RuntimeException("test-exception"); - when(mockExec.getQueues()).thenThrow(exception); + when(mockExec.getStaticQueues()).thenThrow(exception); try (var server = new AdminServer(port, mockExec, mockDB)) { server.start(); @@ -200,7 +200,7 @@ public void queueMetadata() throws IOException { .withPriorityEnabled(true) .withRateLimit(2, 4, TimeUnit.SECONDS); - when(mockExec.getQueues()).thenReturn(List.of(queue1, queue2)); + when(mockExec.getStaticQueues()).thenReturn(List.of(queue1, queue2)); try (var server = new AdminServer(port, mockExec, mockDB)) { server.start(); From f3de1a00108a68fdc1e5a966b7a3bf5f7c153635 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 17:20:46 -0700 Subject: [PATCH 16/23] add more tests + fix truncateDbosTables --- .../dbos/transact/client/ClientQueueTest.java | 114 ++++++++++++++++++ .../transact/queue/DynamicQueuesTest.java | 78 ++++++++++++ .../dev/dbos/transact/utils/PgContainer.java | 3 +- 3 files changed, 194 insertions(+), 1 deletion(-) create mode 100644 transact/src/test/java/dev/dbos/transact/client/ClientQueueTest.java diff --git a/transact/src/test/java/dev/dbos/transact/client/ClientQueueTest.java b/transact/src/test/java/dev/dbos/transact/client/ClientQueueTest.java new file mode 100644 index 00000000..2487d32a --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/client/ClientQueueTest.java @@ -0,0 +1,114 @@ +package dev.dbos.transact.client; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.dbos.transact.utils.PgContainer; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueConflictResolution; +import dev.dbos.transact.workflow.QueueOptions; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.Test; + +public class ClientQueueTest { + + @AutoClose final PgContainer pgContainer = new PgContainer(); + + @Test + public void testClientRegisterAndFindQueue() { + try (var client = pgContainer.dbosClient()) { + client.registerQueue("cq-find", QueueOptions.setConcurrency(7)); + + var q = client.findQueue("cq-find").orElseThrow(); + assertEquals("cq-find", q.name()); + assertEquals(7, q.concurrency()); + + assertTrue(client.findQueue("cq-does-not-exist").isEmpty()); + } + } + + @Test + public void testClientListQueues() { + try (var client = pgContainer.dbosClient()) { + client.registerQueue("cq-list-1", QueueOptions.setConcurrency(1)); + client.registerQueue("cq-list-2", QueueOptions.setConcurrency(2)); + client.registerQueue("cq-list-3", QueueOptions.empty()); + + var names = client.listQueues().stream().map(Queue::name).toList(); + assertTrue(names.contains("cq-list-1")); + assertTrue(names.contains("cq-list-2")); + assertTrue(names.contains("cq-list-3")); + } + } + + @Test + public void testClientDeleteQueue() { + try (var client = pgContainer.dbosClient()) { + client.registerQueue("cq-del", QueueOptions.empty()); + assertTrue(client.findQueue("cq-del").isPresent()); + + assertTrue(client.deleteQueue("cq-del")); + assertTrue(client.findQueue("cq-del").isEmpty()); + + assertFalse(client.deleteQueue("cq-never-existed")); + } + } + + @Test + public void testClientUpdateQueue() { + try (var client = pgContainer.dbosClient()) { + client.registerQueue("cq-update", QueueOptions.setConcurrency(5)); + assertEquals(5, client.findQueue("cq-update").orElseThrow().concurrency()); + + client.updateQueue("cq-update", QueueOptions.setConcurrency(10)); + assertEquals(10, client.findQueue("cq-update").orElseThrow().concurrency()); + } + } + + @Test + public void testClientNeverUpdate() { + try (var client = pgContainer.dbosClient()) { + client.registerQueue("cq-never", QueueOptions.setConcurrency(5)); + client.registerQueue( + "cq-never", QueueOptions.setConcurrency(99), QueueConflictResolution.NEVER_UPDATE); + + assertEquals(5, client.findQueue("cq-never").orElseThrow().concurrency()); + } + } + + @Test + public void testClientAlwaysUpdate() { + try (var client = pgContainer.dbosClient()) { + client.registerQueue("cq-always", QueueOptions.setConcurrency(5)); + client.registerQueue( + "cq-always", QueueOptions.setConcurrency(99), QueueConflictResolution.ALWAYS_UPDATE); + + assertEquals(99, client.findQueue("cq-always").orElseThrow().concurrency()); + } + } + + @Test + public void testClientRejectsUpdateIfLatestVersion() { + try (var client = pgContainer.dbosClient()) { + assertThrows( + IllegalArgumentException.class, + () -> + client.registerQueue( + "cq-version", + QueueOptions.empty(), + QueueConflictResolution.UPDATE_IF_LATEST_VERSION)); + } + } + + @Test + public void testClientRejectsInternalQueueName() { + try (var client = pgContainer.dbosClient()) { + assertThrows( + IllegalArgumentException.class, + () -> client.registerQueue("_dbos_internal_queue", QueueOptions.empty())); + } + } +} diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index 6b5d23b2..86504e5e 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -192,4 +192,82 @@ public void testRegisterInternalQueueThrows() throws Exception { IllegalArgumentException.class, () -> dbos.registerQueue("_dbos_internal_queue", QueueOptions.empty())); } + + @Test + public void testDeleteAndRecreateQueue() throws Exception { + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + + dbos.registerQueue("q-lifecycle", QueueOptions.setConcurrency(5)); + + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("first"), + new StartWorkflowOptions("lc-wf1").withQueue("q-lifecycle")); + assertEquals("firstfirst", h1.getResult()); + + dbos.deleteQueue("q-lifecycle"); + assertFalse(dbos.listQueues().stream().anyMatch(x -> x.name().equals("q-lifecycle"))); + + // Recreate with different config. + dbos.registerQueue("q-lifecycle", QueueOptions.setConcurrency(2)); + var recreated = + dbos.listQueues().stream() + .filter(x -> x.name().equals("q-lifecycle")) + .findFirst() + .orElseThrow(); + assertEquals(2, recreated.concurrency()); + + var h2 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("second"), + new StartWorkflowOptions("lc-wf2").withQueue("q-lifecycle")); + assertEquals("secondsecond", h2.getResult()); + } + + @Test + public void testStaticAndDynamicQueueSameName() throws Exception { + // Static queue registered pre-launch. + var staticQ = new Queue("q-shared").withConcurrency(3); + dbos.registerQueue(staticQ); + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + + // Register same name as a dynamic queue — supervisor should ignore the DB entry. + dbos.registerQueue("q-shared", QueueOptions.setConcurrency(99)); + + // Workflow still executes — static listener handles it. + var handle = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("shared"), + new StartWorkflowOptions().withQueue("q-shared")); + assertEquals("sharedshared", handle.getResult()); + assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); + } + + @Test + public void testRegisterQueueValidation() throws Exception { + dbos.launch(); + + // Zero or negative polling interval should fail. + assertThrows( + IllegalArgumentException.class, + () -> + dbos.registerQueue( + "q-bad-poll", QueueOptions.setPollingInterval(Duration.ofSeconds(-1)))); + assertThrows( + IllegalArgumentException.class, + () -> dbos.registerQueue("q-bad-poll", QueueOptions.setPollingInterval(Duration.ZERO))); + + // Zero or negative concurrency should fail. + assertThrows( + IllegalArgumentException.class, + () -> dbos.registerQueue("q-bad-conc", QueueOptions.setConcurrency(0))); + } } diff --git a/transact/src/test/java/dev/dbos/transact/utils/PgContainer.java b/transact/src/test/java/dev/dbos/transact/utils/PgContainer.java index 6c029791..b623f87d 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/PgContainer.java +++ b/transact/src/test/java/dev/dbos/transact/utils/PgContainer.java @@ -76,7 +76,8 @@ public static void truncateDbosTables(Connection conn) throws SQLException { "dbos".event_dispatch_kv, "dbos".streams, "dbos".application_versions, - "dbos".workflow_schedules + "dbos".workflow_schedules, + "dbos".queues CASCADE """; try (var stmt = conn.createStatement()) { From 3a55834b2500372fb7b6f0d25c796d70cd07310d Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Fri, 22 May 2026 10:58:00 -0700 Subject: [PATCH 17/23] fix queues int columns for CRDB --- .../java/dev/dbos/transact/migrations/MigrationManager.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java b/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java index ea890bfc..bad6c638 100644 --- a/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java +++ b/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java @@ -821,9 +821,9 @@ static String migration20(boolean useListenNotify, boolean isCockroach) { CREATE TABLE "%1$s".queues ( queue_id TEXT PRIMARY KEY DEFAULT gen_random_uuid()::TEXT, name TEXT NOT NULL UNIQUE, - concurrency INTEGER, - worker_concurrency INTEGER, - rate_limit_max INTEGER, + concurrency INT4, + worker_concurrency INT4, + rate_limit_max INT4, rate_limit_period_sec DOUBLE PRECISION, priority_enabled BOOLEAN NOT NULL DEFAULT FALSE, partition_queue BOOLEAN NOT NULL DEFAULT FALSE, From dedd62250e068cd162c4195e5c41db017c048530 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Fri, 22 May 2026 12:38:52 -0700 Subject: [PATCH 18/23] copilot feedback --- .../dbos/transact/database/dao/QueuesDAO.java | 133 ++++++++++-------- .../dbos/transact/execution/QueueService.java | 35 ++--- .../dev/dbos/transact/workflow/Field.java | 3 +- .../dbos/transact/workflow/QueueOptions.java | 47 ++++--- .../transact/database/SystemDatabaseTest.java | 11 +- .../transact/queue/DynamicQueuesTest.java | 2 +- 6 files changed, 118 insertions(+), 113 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java index 19a2ec49..a517d136 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/QueuesDAO.java @@ -306,68 +306,77 @@ public static boolean upsertQueue( DbContext ctx, String name, QueueOptions options, boolean updateExisting) throws SQLException { Queue queue = queueFromOptions(name, options); - final String conflictClause = - updateExisting - ? """ - ON CONFLICT (name) DO UPDATE SET - concurrency = EXCLUDED.concurrency, - worker_concurrency = EXCLUDED.worker_concurrency, - rate_limit_max = EXCLUDED.rate_limit_max, - rate_limit_period_sec = EXCLUDED.rate_limit_period_sec, - priority_enabled = EXCLUDED.priority_enabled, - partition_queue = EXCLUDED.partition_queue, - polling_interval_sec = EXCLUDED.polling_interval_sec, - updated_at = EXCLUDED.updated_at - """ - : "ON CONFLICT (name) DO NOTHING"; final String insertSql = """ INSERT INTO "%s".queues (name, concurrency, worker_concurrency, rate_limit_max, rate_limit_period_sec, priority_enabled, partition_queue, polling_interval_sec, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - %s + ON CONFLICT (name) DO NOTHING """ - .formatted(ctx.schema(), conflictClause); - final String existsSql = "SELECT 1 FROM \"%s\".queues WHERE name = ?".formatted(ctx.schema()); + .formatted(ctx.schema()); + final String updateSql = + """ + UPDATE "%s".queues SET + concurrency = ?, + worker_concurrency = ?, + rate_limit_max = ?, + rate_limit_period_sec = ?, + priority_enabled = ?, + partition_queue = ?, + polling_interval_sec = ?, + updated_at = ? + WHERE name = ? + """ + .formatted(ctx.schema()); try (Connection connection = ctx.getConnection()) { - connection.setAutoCommit(false); - try { - boolean existed; - try (PreparedStatement ps = connection.prepareStatement(existsSql)) { - ps.setString(1, queue.name()); - try (ResultSet rs = ps.executeQuery()) { - existed = rs.next(); - } - } - - try (PreparedStatement ps = connection.prepareStatement(insertSql)) { - ps.setString(1, queue.name()); - setNullableInt(ps, 2, queue.concurrency()); - setNullableInt(ps, 3, queue.workerConcurrency()); + boolean inserted; + try (PreparedStatement ps = connection.prepareStatement(insertSql)) { + bindQueueParams(ps, queue, 1); + inserted = ps.executeUpdate() == 1; + } + if (!inserted && updateExisting) { + try (PreparedStatement ps = connection.prepareStatement(updateSql)) { + setNullableInt(ps, 1, queue.concurrency()); + setNullableInt(ps, 2, queue.workerConcurrency()); var rateLimit = queue.rateLimit(); if (rateLimit != null) { - ps.setInt(4, rateLimit.limit()); - ps.setDouble(5, rateLimit.period().toMillis() / 1000.0); + ps.setInt(3, rateLimit.limit()); + ps.setDouble(4, rateLimit.period().toMillis() / 1000.0); } else { - ps.setNull(4, java.sql.Types.INTEGER); - ps.setNull(5, java.sql.Types.DOUBLE); + ps.setNull(3, java.sql.Types.INTEGER); + ps.setNull(4, java.sql.Types.DOUBLE); } - ps.setBoolean(6, queue.priorityEnabled()); - ps.setBoolean(7, queue.partitioningEnabled()); - ps.setDouble(8, queue.pollingInterval().toMillis() / 1000.0); - ps.setLong(9, System.currentTimeMillis()); + ps.setBoolean(5, queue.priorityEnabled()); + ps.setBoolean(6, queue.partitioningEnabled()); + ps.setDouble(7, queue.pollingInterval().toMillis() / 1000.0); + ps.setLong(8, System.currentTimeMillis()); + ps.setString(9, queue.name()); ps.executeUpdate(); } - - connection.commit(); - return !existed; - } catch (SQLException e) { - connection.rollback(); - throw e; } + return inserted; + } + } + + private static void bindQueueParams(PreparedStatement ps, Queue queue, int offset) + throws SQLException { + ps.setString(offset, queue.name()); + setNullableInt(ps, offset + 1, queue.concurrency()); + setNullableInt(ps, offset + 2, queue.workerConcurrency()); + var rateLimit = queue.rateLimit(); + if (rateLimit != null) { + ps.setInt(offset + 3, rateLimit.limit()); + ps.setDouble(offset + 4, rateLimit.period().toMillis() / 1000.0); + } else { + ps.setNull(offset + 3, java.sql.Types.INTEGER); + ps.setNull(offset + 4, java.sql.Types.DOUBLE); } + ps.setBoolean(offset + 5, queue.priorityEnabled()); + ps.setBoolean(offset + 6, queue.partitioningEnabled()); + ps.setDouble(offset + 7, queue.pollingInterval().toMillis() / 1000.0); + ps.setLong(offset + 8, System.currentTimeMillis()); } public static Optional findQueue(DbContext ctx, String name) throws SQLException { @@ -427,9 +436,9 @@ public static void updateQueue(DbContext ctx, String name, QueueOptions update) collectField(setClauses, params, "rate_limit_max", update.rateLimitMax()); collectField( setClauses, params, "rate_limit_period_sec", durationToSec(update.rateLimitPeriod())); - collectField(setClauses, params, "priority_enabled", update.priorityEnabled()); - collectField(setClauses, params, "partition_queue", update.partitionQueue()); - collectField( + collectOptional(setClauses, params, "priority_enabled", update.priorityEnabled()); + collectOptional(setClauses, params, "partition_queue", update.partitionQueue()); + collectOptional( setClauses, params, "polling_interval_sec", durationToSec(update.pollingInterval())); setClauses.add("\"updated_at\" = ?"); @@ -457,12 +466,25 @@ private static void collectField( } } + private static void collectOptional( + List clauses, List params, String column, Optional opt) { + opt.ifPresent( + value -> { + clauses.add("\"" + column + "\" = ?"); + params.add(value); + }); + } + private static Field durationToSec(Field field) { if (!field.isPresent()) return Field.absent(); Duration d = field.get(); return Field.of(d != null ? d.toMillis() / 1000.0 : null); } + private static Optional durationToSec(Optional opt) { + return opt.map(d -> d.toMillis() / 1000.0); + } + public static boolean deleteQueue(DbContext ctx, String name) throws SQLException { final String sql = "DELETE FROM \"%s\".queues WHERE name = ?".formatted(ctx.schema()); @@ -506,10 +528,8 @@ private static Queue queueFromOptions(String name, QueueOptions options) { Integer concurrencyVal = options.concurrency().isPresent() ? options.concurrency().get() : null; Integer workerConcurrencyVal = options.workerConcurrency().isPresent() ? options.workerConcurrency().get() : null; - Boolean priorityEnabledVal = - options.priorityEnabled().isPresent() ? options.priorityEnabled().get() : null; - Boolean partitionQueueVal = - options.partitionQueue().isPresent() ? options.partitionQueue().get() : null; + boolean priorityEnabledVal = options.priorityEnabled().orElse(false); + boolean partitionQueueVal = options.partitionQueue().orElse(false); Queue.RateLimit rateLimit = null; if (options.rateLimitMax().isPresent() @@ -520,17 +540,14 @@ private static Queue queueFromOptions(String name, QueueOptions options) { new Queue.RateLimit(options.rateLimitMax().get(), options.rateLimitPeriod().get()); } - Duration pollingIntervalVal = Queue.DEFAULT_POLLING_INTERVAL; - if (options.pollingInterval().isPresent() && options.pollingInterval().get() != null) { - pollingIntervalVal = options.pollingInterval().get(); - } + Duration pollingIntervalVal = options.pollingInterval().orElse(Queue.DEFAULT_POLLING_INTERVAL); return new Queue( name, concurrencyVal, workerConcurrencyVal, - Boolean.TRUE.equals(priorityEnabledVal), - Boolean.TRUE.equals(partitionQueueVal), + priorityEnabledVal, + partitionQueueVal, rateLimit, pollingIntervalVal); } diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 2d602fe4..45506fec 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -15,7 +15,6 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; -import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -23,7 +22,6 @@ public class QueueService implements AutoCloseable { private static final Logger logger = LoggerFactory.getLogger(QueueService.class); - private static final Duration MIN_POLLING_INTERVAL = Duration.ofSeconds(1); private static final Duration MAX_POLLING_INTERVAL = Duration.ofSeconds(120); private static final long DB_QUEUE_SUPERVISOR_INTERVAL_SEC = 1; @@ -115,11 +113,6 @@ private void pollDynamicQueues() { } } - // Remove listeners for queues deleted from DB; listener tasks self-terminate when they - // next fire and find their name absent from dbListeningQueues. - var dbQueueNames = dbQueues.stream().map(Queue::name).collect(Collectors.toSet()); - dbListeningQueues.removeIf(name -> !dbQueueNames.contains(name)); - for (var queue : dbQueues) { if (dbosExecutor.findStaticQueue(queue.name()).isPresent()) { logger.warn( @@ -140,20 +133,20 @@ private void pollDynamicQueues() { private class QueueListenerTask implements Runnable { Queue queue; - Duration pollingInterval; + double backoffFactor = 1.0; final boolean dynamic; final String executorId = dbosExecutor.executorId(); final String appVersion = dbosExecutor.appVersion(); QueueListenerTask(Queue queue, boolean dynamic) { this.queue = queue; - this.pollingInterval = queue.pollingInterval(); this.dynamic = dynamic; } void schedule() { var randomSleepFactor = 0.95 + ThreadLocalRandom.current().nextDouble(0.1); - var delayMs = (long) (randomSleepFactor * pollingInterval.toMillis() * speedup); + var delayMs = + (long) (randomSleepFactor * queue.pollingInterval().toMillis() * backoffFactor * speedup); var svc = execServiceRef.get(); if (svc != null) { svc.schedule(this, delayMs, TimeUnit.MILLISECONDS); @@ -186,9 +179,13 @@ private void processPartition(String partition) { @Override public void run() { if (execServiceRef.get() == null) return; - if (dynamic && !dbListeningQueues.contains(queue.name())) return; if (dynamic) { - queue = systemDatabase.findQueue(queue.name()).orElse(queue); + var refreshed = systemDatabase.findQueue(queue.name()); + if (refreshed.isEmpty()) { + dbListeningQueues.remove(queue.name()); + return; + } + queue = refreshed.get(); } try { @@ -201,18 +198,12 @@ public void run() { processPartition(null); } - pollingInterval = Duration.ofMillis((long) (pollingInterval.toMillis() * 0.9)); - pollingInterval = - pollingInterval.compareTo(MIN_POLLING_INTERVAL) >= 0 - ? pollingInterval - : MIN_POLLING_INTERVAL; + backoffFactor = Math.max(backoffFactor * 0.9, 1.0); } catch (Exception e) { logger.error("Error executing queued workflow(s) for queue {}", queue.name(), e); - pollingInterval = pollingInterval.multipliedBy(2); - pollingInterval = - pollingInterval.compareTo(MAX_POLLING_INTERVAL) <= 0 - ? pollingInterval - : MAX_POLLING_INTERVAL; + double maxFactor = + (double) MAX_POLLING_INTERVAL.toMillis() / queue.pollingInterval().toMillis(); + backoffFactor = Math.min(backoffFactor * 2.0, maxFactor); } finally { this.schedule(); } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Field.java b/transact/src/main/java/dev/dbos/transact/workflow/Field.java index a5f3ff82..d7f75c84 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Field.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Field.java @@ -25,6 +25,7 @@ default boolean isPresent() { } default @Nullable T get() { - return ((Present) this).value(); + if (this instanceof Present p) return p.value(); + throw new IllegalStateException("Field.get() called on an absent field"); } } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java index ea2ffeaf..1a679cff 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/QueueOptions.java @@ -1,6 +1,7 @@ package dev.dbos.transact.workflow; import java.time.Duration; +import java.util.Optional; import java.util.concurrent.TimeUnit; import org.jspecify.annotations.Nullable; @@ -12,15 +13,19 @@ *

When used for registration, absent and null fields both result in the column being null (no * limit / use default). When used for updates, absent fields are left unchanged in the database * while null-valued fields clear the column. + * + *

The non-nullable queue properties ({@code priorityEnabled}, {@code partitionQueue}, {@code + * pollingInterval}) use {@link Optional} — {@link Optional#empty()} means use the default on + * creation or leave unchanged on update; a present value sets the column. */ public record QueueOptions( Field concurrency, Field workerConcurrency, Field rateLimitMax, Field rateLimitPeriod, - Field priorityEnabled, - Field partitionQueue, - Field pollingInterval) { + Optional priorityEnabled, + Optional partitionQueue, + Optional pollingInterval) { private static final QueueOptions EMPTY = new QueueOptions( @@ -28,9 +33,9 @@ public record QueueOptions( Field.absent(), Field.absent(), Field.absent(), - Field.absent(), - Field.absent(), - Field.absent()); + Optional.empty(), + Optional.empty(), + Optional.empty()); public static QueueOptions empty() { return EMPTY; @@ -41,9 +46,9 @@ public boolean isEmpty() { && !workerConcurrency.isPresent() && !rateLimitMax.isPresent() && !rateLimitPeriod.isPresent() - && !priorityEnabled.isPresent() - && !partitionQueue.isPresent() - && !pollingInterval.isPresent(); + && priorityEnabled.isEmpty() + && partitionQueue.isEmpty() + && pollingInterval.isEmpty(); } // ── Static factories ────────────────────────────────────────────────────── @@ -65,15 +70,15 @@ public static QueueOptions setRateLimit(int limit, long period, TimeUnit unit) { } public static QueueOptions setPriorityEnabled(boolean value) { - return EMPTY.withPriorityEnabled(Field.of(value)); + return EMPTY.withPriorityEnabled(Optional.of(value)); } public static QueueOptions setPartitionQueue(boolean value) { - return EMPTY.withPartitionQueue(Field.of(value)); + return EMPTY.withPartitionQueue(Optional.of(value)); } - public static QueueOptions setPollingInterval(@Nullable Duration value) { - return EMPTY.withPollingInterval(Field.of(value)); + public static QueueOptions setPollingInterval(Duration value) { + return EMPTY.withPollingInterval(Optional.of(value)); } // ── Builders for chaining ───────────────────────────────────────────────── @@ -122,7 +127,7 @@ public QueueOptions withRateLimitPeriod(Field rateLimitPeriod) { pollingInterval); } - public QueueOptions withPriorityEnabled(Field priorityEnabled) { + public QueueOptions withPriorityEnabled(Optional priorityEnabled) { return new QueueOptions( concurrency, workerConcurrency, @@ -133,7 +138,7 @@ public QueueOptions withPriorityEnabled(Field priorityEnabled) { pollingInterval); } - public QueueOptions withPartitionQueue(Field partitionQueue) { + public QueueOptions withPartitionQueue(Optional partitionQueue) { return new QueueOptions( concurrency, workerConcurrency, @@ -144,7 +149,7 @@ public QueueOptions withPartitionQueue(Field partitionQueue) { pollingInterval); } - public QueueOptions withPollingInterval(Field pollingInterval) { + public QueueOptions withPollingInterval(Optional pollingInterval) { return new QueueOptions( concurrency, workerConcurrency, @@ -155,7 +160,7 @@ public QueueOptions withPollingInterval(Field pollingInterval) { pollingInterval); } - // ── Convenience chaining methods (take raw values, wrap in Field.of) ──── + // ── Convenience chaining methods ────────────────────────────────────────── public QueueOptions andConcurrency(@Nullable Integer value) { return withConcurrency(Field.of(value)); @@ -174,14 +179,14 @@ public QueueOptions andRateLimit(int max, long period, TimeUnit unit) { } public QueueOptions andPriorityEnabled(boolean value) { - return withPriorityEnabled(Field.of(value)); + return withPriorityEnabled(Optional.of(value)); } public QueueOptions andPartitionQueue(boolean value) { - return withPartitionQueue(Field.of(value)); + return withPartitionQueue(Optional.of(value)); } - public QueueOptions andPollingInterval(@Nullable Duration value) { - return withPollingInterval(Field.of(value)); + public QueueOptions andPollingInterval(Duration value) { + return withPollingInterval(Optional.of(value)); } } diff --git a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java index 9539658f..0a737276 100644 --- a/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java +++ b/transact/src/test/java/dev/dbos/transact/database/SystemDatabaseTest.java @@ -21,7 +21,6 @@ import dev.dbos.transact.utils.WorkflowStatusBuilder; import dev.dbos.transact.utils.WorkflowStatusInternalBuilder; import dev.dbos.transact.workflow.ExportedWorkflow; -import dev.dbos.transact.workflow.Field; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.GetWorkflowAggregatesInput; import dev.dbos.transact.workflow.Queue; @@ -1700,15 +1699,7 @@ public void testUpdateQueueEmpty() { sysdb.upsertQueue("q-empty-update", QueueOptions.setConcurrency(5), true); // Empty update should be a no-op (no exception, no change) - var emptyUpdate = - new QueueOptions( - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent(), - Field.absent()); + var emptyUpdate = QueueOptions.empty(); sysdb.updateQueue("q-empty-update", emptyUpdate); var q = sysdb.findQueue("q-empty-update").orElseThrow(); diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index 86504e5e..14d6a623 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -48,7 +48,7 @@ public void testDynamicQueueWorkflowExecution() throws Exception { // Register a dynamic queue after launch — this writes to DB. dbos.registerQueue("dynQueue", QueueOptions.empty()); - // The supervisor polls every 5s; wait for it to discover and start a listener. + // The supervisor polls every 1s; wait for it to discover and start a listener. var handle = dbos.startWorkflow( () -> serviceQ.simpleQWorkflow("hello"), From 0e51c0bf52d5a2f91511c99a9147e61ae7d2d099 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Sun, 24 May 2026 08:28:17 -0700 Subject: [PATCH 19/23] more tests --- .../transact/queue/DynamicQueuesTest.java | 50 ++ .../dev/dbos/transact/queue/QueuesTest.java | 157 ++-- .../dbos/transact/queue/StaticQueuesTest.java | 684 ++++++++++++++++++ 3 files changed, 813 insertions(+), 78 deletions(-) create mode 100644 transact/src/test/java/dev/dbos/transact/queue/StaticQueuesTest.java diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index 14d6a623..7ca1945b 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -212,6 +212,11 @@ public void testDeleteAndRecreateQueue() throws Exception { dbos.deleteQueue("q-lifecycle"); assertFalse(dbos.listQueues().stream().anyMatch(x -> x.name().equals("q-lifecycle"))); + // Wait for the old listener to detect the deletion and remove itself from the + // active-listener set. Without this wait the supervisor may not start a fresh + // listener for the recreated queue (dbListeningQueues still contains the name). + Thread.sleep(500); + // Recreate with different config. dbos.registerQueue("q-lifecycle", QueueOptions.setConcurrency(2)); var recreated = @@ -251,6 +256,51 @@ public void testStaticAndDynamicQueueSameName() throws Exception { assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); } + @Test + public void testDynamicConcurrencyTakesEffect() throws Exception { + ConcurrencyTestServiceImpl impl = new ConcurrencyTestServiceImpl(); + ConcurrencyTestService service = dbos.registerProxy(ConcurrencyTestService.class, impl); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + + // Start with concurrency=1 so only one workflow dequeues at a time. + dbos.registerQueue("dyn-update-q", QueueOptions.setConcurrency(1)); + + var h1 = + dbos.startWorkflow( + () -> service.blockedWorkflow(0), + new StartWorkflowOptions("dyn-wf1").withQueue("dyn-update-q")); + var h2 = + dbos.startWorkflow( + () -> service.blockedWorkflow(1), + new StartWorkflowOptions("dyn-wf2").withQueue("dyn-update-q")); + var h3 = + dbos.startWorkflow( + () -> service.blockedWorkflow(2), + new StartWorkflowOptions("dyn-wf3").withQueue("dyn-update-q")); + + // Wait for exactly one workflow to be dequeued and start running. + impl.wfSemaphore.acquire(1); + + // With concurrency=1 the other two should still be waiting. + Thread.sleep(200); + assertEquals(WorkflowState.ENQUEUED, h2.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, h3.getStatus().status()); + + // Bump concurrency. The runner reloads queue settings on its next poll and + // should immediately dequeue the remaining two workflows. + dbos.updateQueue("dyn-update-q", QueueOptions.setConcurrency(3)); + impl.wfSemaphore.acquire(2); + + // Release all blocked workflows and verify they complete successfully. + impl.latch.countDown(); + assertEquals(0, h1.getResult()); + assertEquals(1, h2.getResult()); + assertEquals(2, h3.getResult()); + } + @Test public void testRegisterQueueValidation() throws Exception { dbos.launch(); diff --git a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java index 7140fdcc..f9a6dec1 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java @@ -17,6 +17,7 @@ import dev.dbos.transact.utils.WorkflowStatusInternalBuilder; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.QueueOptions; import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowState; import dev.dbos.transact.workflow.WorkflowStatus; @@ -51,6 +52,8 @@ void beforeEach() { dataSource = pgContainer.dataSource(); } + // One dedicated in-memory queue test to guard against regressions on the + // static-queue path. All other tests below use database-backed queues. @Test public void testQueuedWorkflow() throws Exception { @@ -73,23 +76,23 @@ public void testQueuedWorkflow() throws Exception { @Test public void testDedupeId() throws Exception { - Queue firstQ = new Queue("firstQueue"); - dbos.registerQueue(firstQ); - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); dbos.launch(); - // pause queue service for test validation var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.empty()); + + // pause queue service for test validation qs.pause(); - var options = new StartWorkflowOptions().withQueue(firstQ); + var options = new StartWorkflowOptions().withQueue("firstQueue"); var dedupeId = "dedupeId"; var h1 = dbos.startWorkflow( () -> serviceQ.simpleQWorkflow("abc"), options.withDeduplicationId(dedupeId)); var s1 = h1.getStatus(); - assertEquals(s1.queueName(), firstQ.name()); + assertEquals(s1.queueName(), "firstQueue"); assertEquals(s1.deduplicationId(), dedupeId); // enqueue with different dedupe ID should be fine @@ -98,13 +101,13 @@ public void testDedupeId() throws Exception { dbos.startWorkflow( () -> serviceQ.simpleQWorkflow("def"), options.withDeduplicationId(dedupeId2)); var s2 = h2.getStatus(); - assertEquals(s2.queueName(), firstQ.name()); + assertEquals(s2.queueName(), "firstQueue"); assertEquals(s2.deduplicationId(), dedupeId2); // enqueue with no dedupe ID should be fine var h3 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); var s3 = h3.getStatus(); - assertEquals(s3.queueName(), firstQ.name()); + assertEquals(s3.queueName(), "firstQueue"); assertNull(s3.deduplicationId()); assertThrows( @@ -139,17 +142,17 @@ public void testDedupeId() throws Exception { @Test public void testDedupeIdWithDelay() throws Exception { - Queue firstQ = new Queue("firstQueue"); - dbos.registerQueue(firstQ); - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); dbos.launch(); var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.empty()); + qs.pause(); var dedupeId = "dedupeId"; - var options = new StartWorkflowOptions().withQueue(firstQ).withDeduplicationId(dedupeId); + var options = new StartWorkflowOptions().withQueue("firstQueue").withDeduplicationId(dedupeId); var h1 = dbos.startWorkflow( () -> serviceQ.simpleQWorkflow("abc"), options.withDelay(Duration.ofHours(1))); @@ -175,28 +178,25 @@ public void testDedupeIdWithDelay() throws Exception { @Test public void testPriority() throws Exception { - Queue firstQ = - new Queue("firstQueue") - .withPriorityEnabled(true) - .withConcurrency(1) - .withWorkerConcurrency(1); - dbos.registerQueue(firstQ); - ServiceQImpl impl = new ServiceQImpl(); ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, impl); - dbos.launch(); var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue( + "firstQueue", + QueueOptions.setPriorityEnabled(true).andConcurrency(1).andWorkerConcurrency(1)); + qs.pause(); - var o1 = new StartWorkflowOptions().withQueue(firstQ).withPriority(100); + var o1 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(100); var h1 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(100), o1); - var o2 = new StartWorkflowOptions().withQueue(firstQ).withPriority(50); + var o2 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(50); var h2 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(50), o2); - var o3 = new StartWorkflowOptions().withQueue(firstQ).withPriority(10); + var o3 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(10); var h3 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(10), o3); qs.unpause(); @@ -214,21 +214,22 @@ public void testPriority() throws Exception { @Test public void testQueuedMultipleWorkflows() throws Exception { - Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); - dbos.registerQueue(firstQ); ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - var queueService = DBOSTestAccess.getQueueService(dbos); - queueService.pause(); + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + + qs.pause(); Thread.sleep(2000); for (int i = 0; i < 5; i++) { String id = "wfid" + i; var input = "inputq" + i; dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow(input), new StartWorkflowOptions(id).withQueue(firstQ)); + () -> serviceQ.simpleQWorkflow(input), + new StartWorkflowOptions(id).withQueue("firstQueue")); } var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); @@ -241,7 +242,7 @@ public void testQueuedMultipleWorkflows() throws Exception { assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); } - queueService.unpause(); + qs.unpause(); for (int i = 0; i < 5; i++) { String id = "wfid" + i; @@ -257,20 +258,21 @@ public void testQueuedMultipleWorkflows() throws Exception { @Test void testListQueuedWorkflow() throws Exception { - Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); - dbos.registerQueue(firstQ); ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - var queueService = DBOSTestAccess.getQueueService(dbos); - queueService.pause(); + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + + qs.pause(); for (int i = 0; i < 5; i++) { String id = "wfid" + i; var input = "inputq" + i; dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow(input), new StartWorkflowOptions(id).withQueue(firstQ)); + () -> serviceQ.simpleQWorkflow(input), + new StartWorkflowOptions(id).withQueue("firstQueue")); Thread.sleep(100); } @@ -301,22 +303,23 @@ void testListQueuedWorkflow() throws Exception { @Test public void multipleQueues() throws Exception { - Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); - Queue secondQ = new Queue("secondQueue").withConcurrency(1).withWorkerConcurrency(1); - dbos.registerQueues(firstQ, secondQ); ServiceQ serviceQ1 = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); ServiceI serviceI = dbos.registerProxy(ServiceI.class, new ServiceIImpl()); - dbos.launch(); + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + dbos.registerQueue("secondQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + String id1 = "firstQ1234"; String id2 = "second1234"; - var options1 = new StartWorkflowOptions(id1).withQueue(firstQ); + var options1 = new StartWorkflowOptions(id1).withQueue("firstQueue"); WorkflowHandle handle1 = dbos.startWorkflow(() -> serviceQ1.simpleQWorkflow("firstinput"), options1); - var options2 = new StartWorkflowOptions(id2).withQueue(secondQ); + var options2 = new StartWorkflowOptions(id2).withQueue("secondQueue"); WorkflowHandle handle2 = dbos.startWorkflow(() -> serviceI.workflowI(25), options2); assertEquals(id1, handle1.workflowId()); @@ -339,18 +342,14 @@ public void testLimiter() throws Exception { double periodSec = 1.8; Duration period = Duration.ofMillis((long) (periodSec * 1000)); - Queue limitQ = - new Queue("limitQueue") - .withRateLimit(limit, period) - .withConcurrency(1) - .withWorkerConcurrency(1); - dbos.registerQueue(limitQ); - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - var queueService = DBOSTestAccess.getQueueService(dbos); - queueService.setSpeedupForTest(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue( + "limitQueue", + QueueOptions.setRateLimit(limit, period).andConcurrency(1).andWorkerConcurrency(1)); Thread.sleep(1000); int numWaves = 3; @@ -360,7 +359,7 @@ public void testLimiter() throws Exception { for (int i = 0; i < numTasks; i++) { String id = "id" + i; - var options = new StartWorkflowOptions(id).withQueue(limitQ); + var options = new StartWorkflowOptions(id).withQueue("limitQueue"); WorkflowHandle handle = dbos.startWorkflow(() -> serviceQ.limitWorkflow("abc", "123"), options); handles.add(handle); @@ -412,15 +411,14 @@ public void testLimiter() throws Exception { @Test public void testWorkerConcurrency() throws Exception { - Queue qwithWCLimit = - new Queue("QwithWCLimit").withConcurrency(1).withWorkerConcurrency(2).withConcurrency(3); - dbos.registerQueue(qwithWCLimit); - dbos.launch(); var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); var queueService = DBOSTestAccess.getQueueService(dbos); + dbos.registerQueue("QwithWCLimit", QueueOptions.setConcurrency(3).andWorkerConcurrency(2)); + Queue qwithWCLimit = dbos.findQueue("QwithWCLimit").get(); + String executorId = dbosExecutor.executorId(); String appVersion = dbosExecutor.appVersion(); @@ -488,14 +486,14 @@ public void testWorkerConcurrency() throws Exception { @Test public void testGlobalConcurrency() throws Exception { - Queue qwithWCLimit = - new Queue("QwithWCLimit").withConcurrency(1).withWorkerConcurrency(2).withConcurrency(3); - dbos.registerQueue(qwithWCLimit); dbos.launch(); var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); var queueService = DBOSTestAccess.getQueueService(dbos); + dbos.registerQueue("QwithWCLimit", QueueOptions.setConcurrency(3).andWorkerConcurrency(2)); + Queue qwithWCLimit = dbos.findQueue("QwithWCLimit").get(); + String executorId = dbosExecutor.executorId(); String appVersion = dbosExecutor.appVersion(); @@ -560,16 +558,16 @@ public void testGlobalConcurrency() throws Exception { @Test public void testenQueueWF() throws Exception { - Queue firstQ = new Queue("firstQueue"); - dbos.registerQueue(firstQ); - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.empty()); + String id = "q1234"; - var option = new StartWorkflowOptions(id).withQueue(firstQ); + var option = new StartWorkflowOptions(id).withQueue("firstQueue"); WorkflowHandle handle = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("inputq"), option); @@ -580,21 +578,21 @@ public void testenQueueWF() throws Exception { @Test public void testQueueConcurrencyUnderRecovery() throws Exception { - Queue queue = new Queue("test_queue").withConcurrency(2); - dbos.registerQueue(queue); - ConcurrencyTestServiceImpl impl = new ConcurrencyTestServiceImpl(); ConcurrencyTestService service = dbos.registerProxy(ConcurrencyTestService.class, impl); - dbos.launch(); - var opt1 = new StartWorkflowOptions("wf1").withQueue(queue); + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("test_queue", QueueOptions.setConcurrency(2)); + + var opt1 = new StartWorkflowOptions("wf1").withQueue("test_queue"); var handle1 = dbos.startWorkflow(() -> service.blockedWorkflow(0), opt1); - var opt2 = new StartWorkflowOptions("wf2").withQueue(queue); + var opt2 = new StartWorkflowOptions("wf2").withQueue("test_queue"); var handle2 = dbos.startWorkflow(() -> service.blockedWorkflow(1), opt2); - var opt3 = new StartWorkflowOptions("wf3").withQueue(queue); + var opt3 = new StartWorkflowOptions("wf3").withQueue("test_queue"); var handle3 = dbos.startWorkflow(() -> service.noopWorkflow(2), opt3); // each call to blockedWorkflow releases the semaphore once, @@ -657,19 +655,22 @@ public void testListenQueue() throws Exception { var config = dbosConfig.withListenQueue("queueOne"); try (var dbos = new DBOS(config)) { - Queue queueOne = new Queue("queueOne"); - Queue queueTwo = new Queue("queueTwo"); - dbos.registerQueues(queueOne, queueTwo); - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); dbos.launch(); + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("queueOne", QueueOptions.empty()); + dbos.registerQueue("queueTwo", QueueOptions.empty()); + var h2 = dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("two"), new StartWorkflowOptions(queueTwo)); + () -> serviceQ.simpleQWorkflow("two"), + new StartWorkflowOptions().withQueue("queueTwo")); var h1 = dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("one"), new StartWorkflowOptions(queueOne)); + () -> serviceQ.simpleQWorkflow("one"), + new StartWorkflowOptions().withQueue("queueOne")); Thread.sleep(3000); assertEquals("oneone", h1.getResult()); diff --git a/transact/src/test/java/dev/dbos/transact/queue/StaticQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/StaticQueuesTest.java new file mode 100644 index 00000000..b4b40468 --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/queue/StaticQueuesTest.java @@ -0,0 +1,684 @@ +package dev.dbos.transact.queue; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.dbos.transact.Constants; +import dev.dbos.transact.DBOS; +import dev.dbos.transact.DBOSTestAccess; +import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.json.SerializationUtil; +import dev.dbos.transact.utils.DBUtils; +import dev.dbos.transact.utils.PgContainer; +import dev.dbos.transact.utils.WorkflowStatusInternalBuilder; +import dev.dbos.transact.workflow.ListWorkflowsInput; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.WorkflowHandle; +import dev.dbos.transact.workflow.WorkflowState; +import dev.dbos.transact.workflow.WorkflowStatus; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Tests for the static (in-memory) queue registration path. All queues here are registered via + * {@code dbos.registerQueue(Queue)} before launch, which exercises the pre-launch static listener + * code path. See {@link QueuesTest} for the equivalent tests using database-backed dynamic queues. + */ +public class StaticQueuesTest { + + private static final Logger logger = LoggerFactory.getLogger(StaticQueuesTest.class); + + @AutoClose final PgContainer pgContainer = new PgContainer(); + + DBOSConfig dbosConfig; + @AutoClose DBOS dbos; + @AutoClose HikariDataSource dataSource; + + @BeforeEach + void beforeEach() { + dbosConfig = pgContainer.dbosConfig(); + dbos = new DBOS(dbosConfig); + dataSource = pgContainer.dataSource(); + } + + @Test + public void testQueuedWorkflow() throws Exception { + + Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); + dbos.registerQueue(firstQ); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + String id = "q1234"; + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("inputq"), new StartWorkflowOptions(id).withQueue(firstQ)); + + var handle = dbos.retrieveWorkflow(id); + assertEquals(id, handle.workflowId()); + String result = (String) handle.getResult(); + assertEquals("inputqinputq", result); + } + + @Test + public void testDedupeId() throws Exception { + + Queue firstQ = new Queue("firstQueue"); + dbos.registerQueue(firstQ); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + // pause queue service for test validation + var qs = DBOSTestAccess.getQueueService(dbos); + qs.pause(); + + var options = new StartWorkflowOptions().withQueue(firstQ); + var dedupeId = "dedupeId"; + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("abc"), options.withDeduplicationId(dedupeId)); + var s1 = h1.getStatus(); + assertEquals(s1.queueName(), firstQ.name()); + assertEquals(s1.deduplicationId(), dedupeId); + + // enqueue with different dedupe ID should be fine + var dedupeId2 = "different-dedupeId"; + var h2 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("def"), options.withDeduplicationId(dedupeId2)); + var s2 = h2.getStatus(); + assertEquals(s2.queueName(), firstQ.name()); + assertEquals(s2.deduplicationId(), dedupeId2); + + // enqueue with no dedupe ID should be fine + var h3 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); + var s3 = h3.getStatus(); + assertEquals(s3.queueName(), firstQ.name()); + assertNull(s3.deduplicationId()); + + assertThrows( + RuntimeException.class, + () -> + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("jkl"), options.withDeduplicationId(dedupeId))); + + // enable queue service to run + qs.unpause(); + + // wait for initial workflow with initial dedupe ID to finish + h1.getResult(); + h2.getResult(); + h3.getResult(); + + var h4 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("jkl"), options.withDeduplicationId(dedupeId)); + h4.getResult(); + + var rows = DBUtils.getWorkflowRows(dataSource); + assertEquals(4, rows.size()); + + for (var row : rows) { + assertEquals(WorkflowState.SUCCESS.name(), row.status()); + assertEquals("firstQueue", row.queueName()); + assertNull(row.deduplicationId()); + } + } + + @Test + public void testDedupeIdWithDelay() throws Exception { + + Queue firstQ = new Queue("firstQueue"); + dbos.registerQueue(firstQ); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.pause(); + + var dedupeId = "dedupeId"; + var options = new StartWorkflowOptions().withQueue(firstQ).withDeduplicationId(dedupeId); + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("abc"), options.withDelay(Duration.ofHours(1))); + var s1 = h1.getStatus(); + assertEquals(WorkflowState.DELAYED, s1.status()); + assertEquals(dedupeId, s1.deduplicationId()); + + // Same dedupe ID should conflict even while DELAYED + assertThrows( + RuntimeException.class, + () -> dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("def"), options)); + + // Clear the delay and run + dbos.setWorkflowDelay(h1.workflowId(), Instant.now().minusSeconds(1)); + qs.unpause(); + h1.getResult(); + + // After completion the dedupe ID is released — re-enqueue should succeed + var h2 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); + h2.getResult(); + } + + @Test + public void testPriority() throws Exception { + + Queue firstQ = + new Queue("firstQueue") + .withPriorityEnabled(true) + .withConcurrency(1) + .withWorkerConcurrency(1); + dbos.registerQueue(firstQ); + + ServiceQImpl impl = new ServiceQImpl(); + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, impl); + + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.pause(); + + var o1 = new StartWorkflowOptions().withQueue(firstQ).withPriority(100); + var h1 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(100), o1); + + var o2 = new StartWorkflowOptions().withQueue(firstQ).withPriority(50); + var h2 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(50), o2); + + var o3 = new StartWorkflowOptions().withQueue(firstQ).withPriority(10); + var h3 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(10), o3); + + qs.unpause(); + + h1.getResult(); + h2.getResult(); + h3.getResult(); + + assertEquals(3, impl.queue.size()); + assertEquals(10, impl.queue.remove()); + assertEquals(50, impl.queue.remove()); + assertEquals(100, impl.queue.remove()); + } + + @Test + public void testQueuedMultipleWorkflows() throws Exception { + + Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); + dbos.registerQueue(firstQ); + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + + dbos.launch(); + + var queueService = DBOSTestAccess.getQueueService(dbos); + queueService.pause(); + Thread.sleep(2000); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + var input = "inputq" + i; + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow(input), new StartWorkflowOptions(id).withQueue(firstQ)); + } + + var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); + List wfs = dbos.listWorkflows(input); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + + assertEquals(id, wfs.get(i).workflowId()); + assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); + } + + queueService.unpause(); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + + var handle = dbos.retrieveWorkflow(id); + assertEquals(id, handle.workflowId()); + String result = (String) handle.getResult(); + assertEquals("inputq" + i + "inputq" + i, result); + assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); + } + } + + @Test + void testListQueuedWorkflow() throws Exception { + + Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); + dbos.registerQueue(firstQ); + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + + dbos.launch(); + var queueService = DBOSTestAccess.getQueueService(dbos); + + queueService.pause(); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + var input = "inputq" + i; + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow(input), new StartWorkflowOptions(id).withQueue(firstQ)); + Thread.sleep(100); + } + + var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); + List wfs = dbos.listWorkflows(input); + wfs.sort( + (a, b) -> { + return a.workflowId().compareTo(b.workflowId()); + }); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + + assertEquals(id, wfs.get(i).workflowId()); + assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); + } + + wfs = dbos.listWorkflows(input.withQueueName("abc")); + assertEquals(0, wfs.size()); + + wfs = dbos.listWorkflows(input.withQueueName("firstQueue")); + assertEquals(5, wfs.size()); + + wfs = dbos.listWorkflows(input.withEndTime(Instant.now().minus(10, ChronoUnit.SECONDS))); + assertEquals(0, wfs.size()); + } + + @Test + public void multipleQueues() throws Exception { + + Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); + Queue secondQ = new Queue("secondQueue").withConcurrency(1).withWorkerConcurrency(1); + dbos.registerQueues(firstQ, secondQ); + ServiceQ serviceQ1 = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + ServiceI serviceI = dbos.registerProxy(ServiceI.class, new ServiceIImpl()); + + dbos.launch(); + + String id1 = "firstQ1234"; + String id2 = "second1234"; + + var options1 = new StartWorkflowOptions(id1).withQueue(firstQ); + WorkflowHandle handle1 = + dbos.startWorkflow(() -> serviceQ1.simpleQWorkflow("firstinput"), options1); + + var options2 = new StartWorkflowOptions(id2).withQueue(secondQ); + WorkflowHandle handle2 = dbos.startWorkflow(() -> serviceI.workflowI(25), options2); + + assertEquals(id1, handle1.workflowId()); + String result = handle1.getResult(); + assertEquals("firstQueue", handle1.getStatus().queueName()); + assertEquals("firstinputfirstinput", result); + assertEquals(WorkflowState.SUCCESS, handle1.getStatus().status()); + + assertEquals(id2, handle2.workflowId()); + Integer result2 = (Integer) handle2.getResult(); + assertEquals("secondQueue", handle2.getStatus().queueName()); + assertEquals(50, result2); + assertEquals(WorkflowState.SUCCESS, handle2.getStatus().status()); + } + + @Test + public void testLimiter() throws Exception { + + int limit = 5; + double periodSec = 1.8; + Duration period = Duration.ofMillis((long) (periodSec * 1000)); + + Queue limitQ = + new Queue("limitQueue") + .withRateLimit(limit, period) + .withConcurrency(1) + .withWorkerConcurrency(1); + dbos.registerQueue(limitQ); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + + dbos.launch(); + var queueService = DBOSTestAccess.getQueueService(dbos); + queueService.setSpeedupForTest(); + Thread.sleep(1000); + + int numWaves = 3; + int numTasks = numWaves * limit; + List> handles = new ArrayList<>(); + List times = new ArrayList<>(); + + for (int i = 0; i < numTasks; i++) { + String id = "id" + i; + var options = new StartWorkflowOptions(id).withQueue(limitQ); + WorkflowHandle handle = + dbos.startWorkflow(() -> serviceQ.limitWorkflow("abc", "123"), options); + handles.add(handle); + } + + for (WorkflowHandle h : handles) { + double result = h.getResult(); + logger.info(String.valueOf(result)); + times.add(result); + } + + double waveTolerance = 1.0; + for (int wave = 0; wave < numWaves; wave++) { + for (int i = wave * limit; i < (wave + 1) * limit - 1; i++) { + double diff = times.get(i + 1) - times.get(i); + logger.info(String.format("Wave %d, Task %d-%d: Time diff %.3f", wave, i, i + 1, diff)); + assertTrue( + diff < waveTolerance, + String.format( + "Wave %d: Tasks %d and %d should start close together. Diff: %.3f", + wave, i, i + 1, diff)); + } + } + logger.info("Verified intra-wave timing."); + + double periodTolerance = 0.5; + for (int wave = 0; wave < numWaves - 1; wave++) { + double startOfNextWave = times.get(limit * (wave + 1)); + double startOfCurrentWave = times.get(limit * wave); + double gap = startOfNextWave - startOfCurrentWave; + logger.info(String.format("Gap between Wave %d and %d: %.3f", wave, wave + 1, gap)); + assertTrue( + gap > periodSec - periodTolerance, + String.format( + "Gap between wave %d and %d should be at least %.3f. Actual: %.3f", + wave, wave + 1, periodSec - periodTolerance, gap)); + assertTrue( + gap < periodSec + periodTolerance, + String.format( + "Gap between wave %d and %d should be at most %.3f. Actual: %.3f", + wave, wave + 1, periodSec + periodTolerance, gap)); + } + + for (WorkflowHandle h : handles) { + assertEquals(WorkflowState.SUCCESS, h.getStatus().status()); + } + } + + @Test + public void testWorkerConcurrency() throws Exception { + + Queue qwithWCLimit = + new Queue("QwithWCLimit").withConcurrency(1).withWorkerConcurrency(2).withConcurrency(3); + dbos.registerQueue(qwithWCLimit); + + dbos.launch(); + var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); + var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); + var queueService = DBOSTestAccess.getQueueService(dbos); + + String executorId = dbosExecutor.executorId(); + String appVersion = dbosExecutor.appVersion(); + + queueService.close(); + while (!queueService.isStopped()) { + Thread.sleep(2000); + logger.info("Waiting for queueService to stop"); + } + + var serArgs = SerializationUtil.serializeValue(new Object[] {"ORD-12345"}, null, null); + var builder = + new WorkflowStatusInternalBuilder() + .workflowName("OrderProcessingWorkflow") + .className("com.example.workflows.OrderWorkflow") + .instanceName("prod-config") + .authenticatedUser("user123@example.com") + .assumedRole("admin") + .authenticatedRoles(new String[] {"admin", "operator"}) + .queueName("QwithWCLimit") + .executorId(executorId) + .appVersion(appVersion) + .appId("order-app-123") + .timeout(Duration.ofMillis(300000)) + .deadline(Instant.ofEpochMilli(System.currentTimeMillis() + 2400000)) + .priority(1) + .inputs(serArgs.serializedValue()); + + for (int i = 0; i < 4; i++) { + String wfid = "id" + i; + var status = builder.workflowId(wfid).deduplicationId("dedup" + i).build(); + systemDatabase.initWorkflowStatus(status, null, false, false); + } + + var readBack = systemDatabase.listWorkflows(new ListWorkflowsInput("id0")).get(0); + assertArrayEquals(new String[] {"admin", "operator"}, readBack.authenticatedRoles()); + + List idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + + assertEquals(2, idsToRun.size()); + + // run the same above 2 are in Pending. + // So no de queueing + idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + assertEquals(0, idsToRun.size()); + + // mark the first 2 as success + DBUtils.updateAllWorkflowStates( + dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); + + // next 2 get dequeued + idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + assertEquals(2, idsToRun.size()); + + DBUtils.updateAllWorkflowStates( + dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); + idsToRun = + systemDatabase.getAndStartQueuedWorkflows( + qwithWCLimit, Constants.DEFAULT_EXECUTORID, Constants.DEFAULT_APP_VERSION, null); + assertEquals(0, idsToRun.size()); + } + + @Test + public void testGlobalConcurrency() throws Exception { + + Queue qwithWCLimit = + new Queue("QwithWCLimit").withConcurrency(1).withWorkerConcurrency(2).withConcurrency(3); + dbos.registerQueue(qwithWCLimit); + dbos.launch(); + var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); + var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); + var queueService = DBOSTestAccess.getQueueService(dbos); + + String executorId = dbosExecutor.executorId(); + String appVersion = dbosExecutor.appVersion(); + + queueService.close(); + while (!queueService.isStopped()) { + Thread.sleep(2000); + logger.info("Waiting for queueService to stop"); + } + + var builder = + new WorkflowStatusInternalBuilder() + .workflowName("OrderProcessingWorkflow") + .className("com.example.workflows.OrderWorkflow") + .instanceName("prod-config") + .authenticatedUser("user123@example.com") + .assumedRole("admin") + .authenticatedRoles(new String[] {"admin", "operator"}) + .queueName("QwithWCLimit") + .executorId(executorId) + .appVersion(appVersion) + .appId("order-app-123") + .timeout(Duration.ofMillis(300000)) + .deadline(Instant.ofEpochMilli(System.currentTimeMillis() + 2400000)) + .priority(1) + .inputs("{\"orderId\":\"ORD-12345\"}"); + + // executor1 + for (int i = 0; i < 2; i++) { + String wfid = "id" + i; + var status = builder.workflowId(wfid).deduplicationId("dedup" + i).build(); + systemDatabase.initWorkflowStatus(status, null, false, false); + } + + // executor2 + String executor2 = "remote"; + for (int i = 2; i < 5; i++) { + String wfid = "id" + i; + var status = + builder.workflowId(wfid).deduplicationId("dedup" + i).executorId(executor2).build(); + systemDatabase.initWorkflowStatus(status, null, false, false); + + DBUtils.setWorkflowState(dataSource, wfid, WorkflowState.PENDING.name()); + } + + List idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + // 0 because global concurrency limit is reached + assertEquals(0, idsToRun.size()); + + DBUtils.updateAllWorkflowStates( + dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); + idsToRun = + systemDatabase.getAndStartQueuedWorkflows( + qwithWCLimit, + // executorId, + executor2, + appVersion, + null); + assertEquals(2, idsToRun.size()); + } + + @Test + public void testenQueueWF() throws Exception { + + Queue firstQ = new Queue("firstQueue"); + dbos.registerQueue(firstQ); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + + dbos.launch(); + + String id = "q1234"; + + var option = new StartWorkflowOptions(id).withQueue(firstQ); + WorkflowHandle handle = + dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("inputq"), option); + + assertEquals(id, handle.workflowId()); + String result = handle.getResult(); + assertEquals("inputqinputq", result); + } + + @Test + public void testQueueConcurrencyUnderRecovery() throws Exception { + Queue queue = new Queue("test_queue").withConcurrency(2); + dbos.registerQueue(queue); + + ConcurrencyTestServiceImpl impl = new ConcurrencyTestServiceImpl(); + ConcurrencyTestService service = dbos.registerProxy(ConcurrencyTestService.class, impl); + + dbos.launch(); + + var opt1 = new StartWorkflowOptions("wf1").withQueue(queue); + var handle1 = dbos.startWorkflow(() -> service.blockedWorkflow(0), opt1); + + var opt2 = new StartWorkflowOptions("wf2").withQueue(queue); + var handle2 = dbos.startWorkflow(() -> service.blockedWorkflow(1), opt2); + + var opt3 = new StartWorkflowOptions("wf3").withQueue(queue); + var handle3 = dbos.startWorkflow(() -> service.noopWorkflow(2), opt3); + + // each call to blockedWorkflow releases the semaphore once, + // so block waiting on both calls to release + impl.wfSemaphore.acquire(2); + + assertEquals(2, impl.counter.get()); + assertEquals(WorkflowState.PENDING, handle1.getStatus().status()); + assertEquals(WorkflowState.PENDING, handle2.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + + // update WF3 to appear as if it's from a different executor + String sql = + "UPDATE dbos.workflow_status SET status = ?, executor_id = ? where workflow_uuid = ?;"; + + try (Connection connection = DBUtils.getConnection(dbosConfig); + PreparedStatement pstmt = connection.prepareStatement(sql)) { + + pstmt.setString(1, WorkflowState.PENDING.name()); + pstmt.setString(2, "other"); + pstmt.setString(3, opt3.workflowId()); + + // Execute the update and get the number of rows affected + int rowsAffected = pstmt.executeUpdate(); + assertEquals(1, rowsAffected); + } + + var executor = DBOSTestAccess.getDbosExecutor(dbos); + List> otherHandles = executor.recoverPendingWorkflows(List.of("other")); + assertEquals(WorkflowState.PENDING, handle1.getStatus().status()); + assertEquals(WorkflowState.PENDING, handle2.getStatus().status()); + assertEquals(1, otherHandles.size()); + assertEquals(otherHandles.get(0).workflowId(), handle3.workflowId()); + assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + + List> localHandles = executor.recoverPendingWorkflows(List.of("local")); + assertEquals(2, localHandles.size()); + List expectedWorkflowIds = List.of(handle1.workflowId(), handle2.workflowId()); + assertTrue(expectedWorkflowIds.contains(localHandles.get(0).workflowId())); + assertTrue(expectedWorkflowIds.contains(localHandles.get(1).workflowId())); + + assertEquals(2, impl.counter.get()); + // Recovery sets back to enqueued. + // The enqueued run will get skipped (first run is still blocked) + assertEquals(WorkflowState.ENQUEUED, handle1.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, handle2.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + + impl.latch.countDown(); + assertEquals(0, handle1.getResult()); + assertEquals(1, handle2.getResult()); + assertEquals(2, handle3.getResult()); + assertEquals("local", handle3.getStatus().executorId()); + + assertTrue(DBUtils.queueEntriesAreCleanedUp(dataSource)); + } + + @Test + public void testListenQueue() throws Exception { + var config = dbosConfig.withListenQueue("queueOne"); + try (var dbos = new DBOS(config)) { + + Queue queueOne = new Queue("queueOne"); + Queue queueTwo = new Queue("queueTwo"); + dbos.registerQueues(queueOne, queueTwo); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var h2 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("two"), new StartWorkflowOptions(queueTwo)); + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("one"), new StartWorkflowOptions(queueOne)); + + Thread.sleep(3000); + assertEquals("oneone", h1.getResult()); + assertEquals(WorkflowState.ENQUEUED, h2.getStatus().status()); + } + } +} From e1cc7dc1da9a37afd2d3a33a4f7801308c7c70a2 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Tue, 26 May 2026 09:46:29 -0700 Subject: [PATCH 20/23] in memory cache of dynamic queues for validation --- .../java/dev/dbos/transact/execution/DBOSExecutor.java | 9 +++++---- .../java/dev/dbos/transact/execution/QueueService.java | 10 ++++++++++ 2 files changed, 15 insertions(+), 4 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java index 56f48409..0a5f5f6b 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -395,7 +395,9 @@ public Optional getRegisteredWorkflow( } public Optional findQueue(String queueName) { - return findStaticQueue(queueName).or(() -> systemDatabase.findQueue(queueName)); + return findStaticQueue(queueName) + .or(() -> queueService.findDynamicQueue(queueName)) + .or(() -> systemDatabase.findQueue(queueName)); } public Collection getStaticQueues() { @@ -1458,8 +1460,7 @@ private void validateWorkflow(String workflowName, String className, String inst private void validateQueue(String queueName) { if (queueName != null) { - findStaticQueue(queueName) - .or(() -> systemDatabase.findQueue(queueName)) + findQueue(queueName) .orElseThrow( () -> new IllegalStateException("Queue %s is not registered".formatted(queueName))); } @@ -1472,7 +1473,7 @@ private void validateQueue(String queueName, String queuePartitionKey) { "DBOS internal queue is not a partitioned queue, but a partition key was provided"); } } else { - var queue = findStaticQueue(queueName).or(() -> systemDatabase.findQueue(queueName)); + var queue = findQueue(queueName); if (queue.isPresent()) { if (queue.get().partitioningEnabled() && queuePartitionKey == null) { throw new IllegalArgumentException( diff --git a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java index 45506fec..50547379 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/QueueService.java +++ b/transact/src/main/java/dev/dbos/transact/execution/QueueService.java @@ -6,7 +6,9 @@ import java.time.Duration; import java.util.Collection; +import java.util.Map; import java.util.Objects; +import java.util.Optional; import java.util.Set; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.Executors; @@ -15,6 +17,7 @@ import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Collectors; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -28,6 +31,7 @@ public class QueueService implements AutoCloseable { private final AtomicReference execServiceRef = new AtomicReference<>(); private final AtomicBoolean paused = new AtomicBoolean(false); private final Set dbListeningQueues = ConcurrentHashMap.newKeySet(); + private volatile Map dynamicQueueMap = Map.of(); private final SystemDatabase systemDatabase; private final DBOSExecutor dbosExecutor; @@ -80,6 +84,10 @@ public boolean isStopped() { return this.execServiceRef.get() == null; } + public Optional findDynamicQueue(String queueName) { + return Optional.ofNullable(dynamicQueueMap.get(queueName)); + } + private boolean isListening(String queueName) { return queueName.equals(Constants.DBOS_INTERNAL_QUEUE) || listenQueues.isEmpty() @@ -102,6 +110,8 @@ private void pollDynamicQueues() { if (execServiceRef.get() == null) return; var dbQueues = systemDatabase.listQueues(); + dynamicQueueMap = + dbQueues.stream().collect(Collectors.toUnmodifiableMap(Queue::name, q -> q)); if (logger.isDebugEnabled()) { logger.debug("pollDynamicQueues found {} queues", dbQueues.size()); for (var q : dbQueues) { From 73340cde991816dfdcac61ae4f9f98b049fdadf3 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Tue, 26 May 2026 10:10:12 -0700 Subject: [PATCH 21/23] add tests that dynamic queue updates are seen by queue service --- .../transact/queue/DynamicQueuesTest.java | 53 +++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index 7ca1945b..c115b852 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -16,6 +16,7 @@ import dev.dbos.transact.workflow.WorkflowState; import java.time.Duration; +import java.util.function.BooleanSupplier; import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.AutoClose; @@ -301,6 +302,58 @@ public void testDynamicConcurrencyTakesEffect() throws Exception { assertEquals(2, h3.getResult()); } + @Test + public void testDynamicQueueMapUpdatedOnRegister() throws Exception { + dbos.launch(); + var qs = DBOSTestAccess.getQueueService(dbos); + + assertFalse(qs.findDynamicQueue("q-map-reg").isPresent()); + + dbos.registerQueue("q-map-reg", QueueOptions.setConcurrency(5)); + awaitCondition(() -> qs.findDynamicQueue("q-map-reg").isPresent()); + + assertEquals(5, qs.findDynamicQueue("q-map-reg").get().concurrency()); + } + + @Test + public void testDynamicQueueMapUpdatedOnUpdate() throws Exception { + dbos.launch(); + var qs = DBOSTestAccess.getQueueService(dbos); + + dbos.registerQueue("q-map-upd", QueueOptions.setConcurrency(5)); + awaitCondition(() -> qs.findDynamicQueue("q-map-upd").isPresent()); + + dbos.updateQueue("q-map-upd", QueueOptions.setConcurrency(10)); + awaitCondition( + () -> + qs.findDynamicQueue("q-map-upd") + .filter(q -> Integer.valueOf(10).equals(q.concurrency())) + .isPresent()); + + assertEquals(10, qs.findDynamicQueue("q-map-upd").get().concurrency()); + } + + @Test + public void testDynamicQueueMapUpdatedOnDelete() throws Exception { + dbos.launch(); + var qs = DBOSTestAccess.getQueueService(dbos); + + dbos.registerQueue("q-map-del", QueueOptions.empty()); + awaitCondition(() -> qs.findDynamicQueue("q-map-del").isPresent()); + + dbos.deleteQueue("q-map-del"); + awaitCondition(() -> qs.findDynamicQueue("q-map-del").isEmpty()); + } + + private static void awaitCondition(BooleanSupplier condition) throws InterruptedException { + long deadline = System.currentTimeMillis() + 2000; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) + throw new AssertionError("Condition not met within 2s"); + Thread.sleep(50); + } + } + @Test public void testRegisterQueueValidation() throws Exception { dbos.launch(); From d4819de22a0b3194bc297efaf3cabfd35ba7a256 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Tue, 26 May 2026 11:17:24 -0700 Subject: [PATCH 22/23] fix flaky test --- transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java index f9a6dec1..27bd8eb4 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java @@ -628,6 +628,8 @@ public void testQueueConcurrencyUnderRecovery() throws Exception { assertEquals(otherHandles.get(0).workflowId(), handle3.workflowId()); assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + // Pause the listener before recovery so it can't race the ENQUEUED status checks below. + qs.pause(); List> localHandles = executor.recoverPendingWorkflows(List.of("local")); assertEquals(2, localHandles.size()); List expectedWorkflowIds = List.of(handle1.workflowId(), handle2.workflowId()); @@ -641,6 +643,7 @@ public void testQueueConcurrencyUnderRecovery() throws Exception { assertEquals(WorkflowState.ENQUEUED, handle2.getStatus().status()); assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + qs.unpause(); impl.latch.countDown(); assertEquals(0, handle1.getResult()); assertEquals(1, handle2.getResult()); From a4ac8a15e562b17d25d114410556de322470f16b Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Tue, 26 May 2026 11:46:15 -0700 Subject: [PATCH 23/23] merge all dynamic queue tests into a single class --- .../transact/queue/DynamicQueuesTest.java | 645 ++++++++++++++++- .../dev/dbos/transact/queue/QueuesTest.java | 683 ------------------ 2 files changed, 636 insertions(+), 692 deletions(-) delete mode 100644 transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java diff --git a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java index c115b852..e8be3e6c 100644 --- a/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java +++ b/transact/src/test/java/dev/dbos/transact/queue/DynamicQueuesTest.java @@ -1,30 +1,49 @@ package dev.dbos.transact.queue; +import static org.junit.jupiter.api.Assertions.assertArrayEquals; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.DBOSTestAccess; import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.json.SerializationUtil; +import dev.dbos.transact.utils.DBUtils; import dev.dbos.transact.utils.PgContainer; +import dev.dbos.transact.utils.WorkflowStatusInternalBuilder; +import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; import dev.dbos.transact.workflow.QueueConflictResolution; import dev.dbos.transact.workflow.QueueOptions; +import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowState; +import dev.dbos.transact.workflow.WorkflowStatus; +import java.sql.Connection; +import java.sql.PreparedStatement; import java.time.Duration; +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; import java.util.function.BooleanSupplier; import com.zaxxer.hikari.HikariDataSource; import org.junit.jupiter.api.AutoClose; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; public class DynamicQueuesTest { + private static final Logger logger = LoggerFactory.getLogger(DynamicQueuesTest.class); + @AutoClose final PgContainer pgContainer = new PgContainer(); DBOSConfig dbosConfig; @@ -345,15 +364,6 @@ public void testDynamicQueueMapUpdatedOnDelete() throws Exception { awaitCondition(() -> qs.findDynamicQueue("q-map-del").isEmpty()); } - private static void awaitCondition(BooleanSupplier condition) throws InterruptedException { - long deadline = System.currentTimeMillis() + 2000; - while (!condition.getAsBoolean()) { - if (System.currentTimeMillis() > deadline) - throw new AssertionError("Condition not met within 2s"); - Thread.sleep(50); - } - } - @Test public void testRegisterQueueValidation() throws Exception { dbos.launch(); @@ -373,4 +383,621 @@ public void testRegisterQueueValidation() throws Exception { IllegalArgumentException.class, () -> dbos.registerQueue("q-bad-conc", QueueOptions.setConcurrency(0))); } + + @Test + public void testDedupeId() throws Exception { + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.empty()); + + // pause queue service for test validation + qs.pause(); + + var options = new StartWorkflowOptions().withQueue("firstQueue"); + var dedupeId = "dedupeId"; + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("abc"), options.withDeduplicationId(dedupeId)); + var s1 = h1.getStatus(); + assertEquals(s1.queueName(), "firstQueue"); + assertEquals(s1.deduplicationId(), dedupeId); + + // enqueue with different dedupe ID should be fine + var dedupeId2 = "different-dedupeId"; + var h2 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("def"), options.withDeduplicationId(dedupeId2)); + var s2 = h2.getStatus(); + assertEquals(s2.queueName(), "firstQueue"); + assertEquals(s2.deduplicationId(), dedupeId2); + + // enqueue with no dedupe ID should be fine + var h3 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); + var s3 = h3.getStatus(); + assertEquals(s3.queueName(), "firstQueue"); + assertNull(s3.deduplicationId()); + + assertThrows( + RuntimeException.class, + () -> + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("jkl"), options.withDeduplicationId(dedupeId))); + + // enable queue service to run + qs.unpause(); + + // wait for initial workflow with initial dedupe ID to finish + h1.getResult(); + h2.getResult(); + h3.getResult(); + + var h4 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("jkl"), options.withDeduplicationId(dedupeId)); + h4.getResult(); + + var rows = DBUtils.getWorkflowRows(dataSource); + assertEquals(4, rows.size()); + + for (var row : rows) { + assertEquals(WorkflowState.SUCCESS.name(), row.status()); + assertEquals("firstQueue", row.queueName()); + assertNull(row.deduplicationId()); + } + } + + @Test + public void testDedupeIdWithDelay() throws Exception { + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.empty()); + + qs.pause(); + + var dedupeId = "dedupeId"; + var options = new StartWorkflowOptions().withQueue("firstQueue").withDeduplicationId(dedupeId); + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("abc"), options.withDelay(Duration.ofHours(1))); + var s1 = h1.getStatus(); + assertEquals(WorkflowState.DELAYED, s1.status()); + assertEquals(dedupeId, s1.deduplicationId()); + + // Same dedupe ID should conflict even while DELAYED + assertThrows( + RuntimeException.class, + () -> dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("def"), options)); + + // Clear the delay and run + dbos.setWorkflowDelay(h1.workflowId(), Instant.now().minusSeconds(1)); + qs.unpause(); + h1.getResult(); + + // After completion the dedupe ID is released — re-enqueue should succeed + var h2 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); + h2.getResult(); + } + + @Test + public void testPriority() throws Exception { + + ServiceQImpl impl = new ServiceQImpl(); + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, impl); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue( + "firstQueue", + QueueOptions.setPriorityEnabled(true).andConcurrency(1).andWorkerConcurrency(1)); + + qs.pause(); + + var o1 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(100); + var h1 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(100), o1); + + var o2 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(50); + var h2 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(50), o2); + + var o3 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(10); + var h3 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(10), o3); + + qs.unpause(); + + h1.getResult(); + h2.getResult(); + h3.getResult(); + + assertEquals(3, impl.queue.size()); + assertEquals(10, impl.queue.remove()); + assertEquals(50, impl.queue.remove()); + assertEquals(100, impl.queue.remove()); + } + + @Test + public void testQueuedMultipleWorkflows() throws Exception { + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + + qs.pause(); + Thread.sleep(2000); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + var input = "inputq" + i; + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow(input), + new StartWorkflowOptions(id).withQueue("firstQueue")); + } + + var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); + List wfs = dbos.listWorkflows(input); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + + assertEquals(id, wfs.get(i).workflowId()); + assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); + } + + qs.unpause(); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + + var handle = dbos.retrieveWorkflow(id); + assertEquals(id, handle.workflowId()); + String result = (String) handle.getResult(); + assertEquals("inputq" + i + "inputq" + i, result); + assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); + } + } + + @Test + void testListQueuedWorkflow() throws Exception { + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + + qs.pause(); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + var input = "inputq" + i; + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow(input), + new StartWorkflowOptions(id).withQueue("firstQueue")); + Thread.sleep(100); + } + + var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); + List wfs = dbos.listWorkflows(input); + wfs.sort( + (a, b) -> { + return a.workflowId().compareTo(b.workflowId()); + }); + + for (int i = 0; i < 5; i++) { + String id = "wfid" + i; + + assertEquals(id, wfs.get(i).workflowId()); + assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); + } + + wfs = dbos.listWorkflows(input.withQueueName("abc")); + assertEquals(0, wfs.size()); + + wfs = dbos.listWorkflows(input.withQueueName("firstQueue")); + assertEquals(5, wfs.size()); + + wfs = dbos.listWorkflows(input.withEndTime(Instant.now().minus(10, ChronoUnit.SECONDS))); + assertEquals(0, wfs.size()); + } + + @Test + public void multipleQueues() throws Exception { + + ServiceQ serviceQ1 = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + ServiceI serviceI = dbos.registerProxy(ServiceI.class, new ServiceIImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + dbos.registerQueue("secondQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); + + String id1 = "firstQ1234"; + String id2 = "second1234"; + + var options1 = new StartWorkflowOptions(id1).withQueue("firstQueue"); + WorkflowHandle handle1 = + dbos.startWorkflow(() -> serviceQ1.simpleQWorkflow("firstinput"), options1); + + var options2 = new StartWorkflowOptions(id2).withQueue("secondQueue"); + WorkflowHandle handle2 = dbos.startWorkflow(() -> serviceI.workflowI(25), options2); + + assertEquals(id1, handle1.workflowId()); + String result = handle1.getResult(); + assertEquals("firstQueue", handle1.getStatus().queueName()); + assertEquals("firstinputfirstinput", result); + assertEquals(WorkflowState.SUCCESS, handle1.getStatus().status()); + + assertEquals(id2, handle2.workflowId()); + Integer result2 = (Integer) handle2.getResult(); + assertEquals("secondQueue", handle2.getStatus().queueName()); + assertEquals(50, result2); + assertEquals(WorkflowState.SUCCESS, handle2.getStatus().status()); + } + + @Test + public void testLimiter() throws Exception { + + int limit = 5; + double periodSec = 1.8; + Duration period = Duration.ofMillis((long) (periodSec * 1000)); + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue( + "limitQueue", + QueueOptions.setRateLimit(limit, period).andConcurrency(1).andWorkerConcurrency(1)); + Thread.sleep(1000); + + int numWaves = 3; + int numTasks = numWaves * limit; + List> handles = new ArrayList<>(); + List times = new ArrayList<>(); + + for (int i = 0; i < numTasks; i++) { + String id = "id" + i; + var options = new StartWorkflowOptions(id).withQueue("limitQueue"); + WorkflowHandle handle = + dbos.startWorkflow(() -> serviceQ.limitWorkflow("abc", "123"), options); + handles.add(handle); + } + + for (WorkflowHandle h : handles) { + double result = h.getResult(); + logger.info(String.valueOf(result)); + times.add(result); + } + + double waveTolerance = 1.0; + for (int wave = 0; wave < numWaves; wave++) { + for (int i = wave * limit; i < (wave + 1) * limit - 1; i++) { + double diff = times.get(i + 1) - times.get(i); + logger.info(String.format("Wave %d, Task %d-%d: Time diff %.3f", wave, i, i + 1, diff)); + assertTrue( + diff < waveTolerance, + String.format( + "Wave %d: Tasks %d and %d should start close together. Diff: %.3f", + wave, i, i + 1, diff)); + } + } + logger.info("Verified intra-wave timing."); + + double periodTolerance = 0.5; + for (int wave = 0; wave < numWaves - 1; wave++) { + double startOfNextWave = times.get(limit * (wave + 1)); + double startOfCurrentWave = times.get(limit * wave); + double gap = startOfNextWave - startOfCurrentWave; + logger.info(String.format("Gap between Wave %d and %d: %.3f", wave, wave + 1, gap)); + assertTrue( + gap > periodSec - periodTolerance, + String.format( + "Gap between wave %d and %d should be at least %.3f. Actual: %.3f", + wave, wave + 1, periodSec - periodTolerance, gap)); + assertTrue( + gap < periodSec + periodTolerance, + String.format( + "Gap between wave %d and %d should be at most %.3f. Actual: %.3f", + wave, wave + 1, periodSec + periodTolerance, gap)); + } + + for (WorkflowHandle h : handles) { + assertEquals(WorkflowState.SUCCESS, h.getStatus().status()); + } + } + + @Test + public void testWorkerConcurrency() throws Exception { + + dbos.launch(); + var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); + var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); + var queueService = DBOSTestAccess.getQueueService(dbos); + + dbos.registerQueue("QwithWCLimit", QueueOptions.setConcurrency(3).andWorkerConcurrency(2)); + Queue qwithWCLimit = dbos.findQueue("QwithWCLimit").get(); + + String executorId = dbosExecutor.executorId(); + String appVersion = dbosExecutor.appVersion(); + + queueService.close(); + while (!queueService.isStopped()) { + Thread.sleep(2000); + logger.info("Waiting for queueService to stop"); + } + + var serArgs = SerializationUtil.serializeValue(new Object[] {"ORD-12345"}, null, null); + var builder = + new WorkflowStatusInternalBuilder() + .workflowName("OrderProcessingWorkflow") + .className("com.example.workflows.OrderWorkflow") + .instanceName("prod-config") + .authenticatedUser("user123@example.com") + .assumedRole("admin") + .authenticatedRoles(new String[] {"admin", "operator"}) + .queueName("QwithWCLimit") + .executorId(executorId) + .appVersion(appVersion) + .appId("order-app-123") + .timeout(Duration.ofMillis(300000)) + .deadline(Instant.ofEpochMilli(System.currentTimeMillis() + 2400000)) + .priority(1) + .inputs(serArgs.serializedValue()); + + for (int i = 0; i < 4; i++) { + String wfid = "id" + i; + var status = builder.workflowId(wfid).deduplicationId("dedup" + i).build(); + systemDatabase.initWorkflowStatus(status, null, false, false); + } + + var readBack = systemDatabase.listWorkflows(new ListWorkflowsInput("id0")).get(0); + assertArrayEquals(new String[] {"admin", "operator"}, readBack.authenticatedRoles()); + + List idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + + assertEquals(2, idsToRun.size()); + + // run the same above 2 are in Pending. + // So no de queueing + idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + assertEquals(0, idsToRun.size()); + + // mark the first 2 as success + DBUtils.updateAllWorkflowStates( + dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); + + // next 2 get dequeued + idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + assertEquals(2, idsToRun.size()); + + DBUtils.updateAllWorkflowStates( + dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); + idsToRun = + systemDatabase.getAndStartQueuedWorkflows( + qwithWCLimit, Constants.DEFAULT_EXECUTORID, Constants.DEFAULT_APP_VERSION, null); + assertEquals(0, idsToRun.size()); + } + + @Test + public void testGlobalConcurrency() throws Exception { + + dbos.launch(); + var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); + var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); + var queueService = DBOSTestAccess.getQueueService(dbos); + + dbos.registerQueue("QwithWCLimit", QueueOptions.setConcurrency(3).andWorkerConcurrency(2)); + Queue qwithWCLimit = dbos.findQueue("QwithWCLimit").get(); + + String executorId = dbosExecutor.executorId(); + String appVersion = dbosExecutor.appVersion(); + + queueService.close(); + while (!queueService.isStopped()) { + Thread.sleep(2000); + logger.info("Waiting for queueService to stop"); + } + + var builder = + new WorkflowStatusInternalBuilder() + .workflowName("OrderProcessingWorkflow") + .className("com.example.workflows.OrderWorkflow") + .instanceName("prod-config") + .authenticatedUser("user123@example.com") + .assumedRole("admin") + .authenticatedRoles(new String[] {"admin", "operator"}) + .queueName("QwithWCLimit") + .executorId(executorId) + .appVersion(appVersion) + .appId("order-app-123") + .timeout(Duration.ofMillis(300000)) + .deadline(Instant.ofEpochMilli(System.currentTimeMillis() + 2400000)) + .priority(1) + .inputs("{\"orderId\":\"ORD-12345\"}"); + + // executor1 + for (int i = 0; i < 2; i++) { + String wfid = "id" + i; + var status = builder.workflowId(wfid).deduplicationId("dedup" + i).build(); + systemDatabase.initWorkflowStatus(status, null, false, false); + } + + // executor2 + String executor2 = "remote"; + for (int i = 2; i < 5; i++) { + String wfid = "id" + i; + var status = + builder.workflowId(wfid).deduplicationId("dedup" + i).executorId(executor2).build(); + systemDatabase.initWorkflowStatus(status, null, false, false); + + DBUtils.setWorkflowState(dataSource, wfid, WorkflowState.PENDING.name()); + } + + List idsToRun = + systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); + // 0 because global concurrency limit is reached + assertEquals(0, idsToRun.size()); + + DBUtils.updateAllWorkflowStates( + dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); + idsToRun = + systemDatabase.getAndStartQueuedWorkflows( + qwithWCLimit, + // executorId, + executor2, + appVersion, + null); + assertEquals(2, idsToRun.size()); + } + + @Test + public void testenQueueWF() throws Exception { + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("firstQueue", QueueOptions.empty()); + + String id = "q1234"; + + var option = new StartWorkflowOptions(id).withQueue("firstQueue"); + WorkflowHandle handle = + dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("inputq"), option); + + assertEquals(id, handle.workflowId()); + String result = handle.getResult(); + assertEquals("inputqinputq", result); + } + + @Test + public void testQueueConcurrencyUnderRecovery() throws Exception { + ConcurrencyTestServiceImpl impl = new ConcurrencyTestServiceImpl(); + ConcurrencyTestService service = dbos.registerProxy(ConcurrencyTestService.class, impl); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("test_queue", QueueOptions.setConcurrency(2)); + + var opt1 = new StartWorkflowOptions("wf1").withQueue("test_queue"); + var handle1 = dbos.startWorkflow(() -> service.blockedWorkflow(0), opt1); + + var opt2 = new StartWorkflowOptions("wf2").withQueue("test_queue"); + var handle2 = dbos.startWorkflow(() -> service.blockedWorkflow(1), opt2); + + var opt3 = new StartWorkflowOptions("wf3").withQueue("test_queue"); + var handle3 = dbos.startWorkflow(() -> service.noopWorkflow(2), opt3); + + // each call to blockedWorkflow releases the semaphore once, + // so block waiting on both calls to release + impl.wfSemaphore.acquire(2); + + assertEquals(2, impl.counter.get()); + assertEquals(WorkflowState.PENDING, handle1.getStatus().status()); + assertEquals(WorkflowState.PENDING, handle2.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + + // update WF3 to appear as if it's from a different executor + String sql = + "UPDATE dbos.workflow_status SET status = ?, executor_id = ? where workflow_uuid = ?;"; + + try (Connection connection = DBUtils.getConnection(dbosConfig); + PreparedStatement pstmt = connection.prepareStatement(sql)) { + + pstmt.setString(1, WorkflowState.PENDING.name()); + pstmt.setString(2, "other"); + pstmt.setString(3, opt3.workflowId()); + + // Execute the update and get the number of rows affected + int rowsAffected = pstmt.executeUpdate(); + assertEquals(1, rowsAffected); + } + + var executor = DBOSTestAccess.getDbosExecutor(dbos); + List> otherHandles = executor.recoverPendingWorkflows(List.of("other")); + assertEquals(WorkflowState.PENDING, handle1.getStatus().status()); + assertEquals(WorkflowState.PENDING, handle2.getStatus().status()); + assertEquals(1, otherHandles.size()); + assertEquals(otherHandles.get(0).workflowId(), handle3.workflowId()); + assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + + // Pause the listener before recovery so it can't race the ENQUEUED status checks below. + qs.pause(); + List> localHandles = executor.recoverPendingWorkflows(List.of("local")); + assertEquals(2, localHandles.size()); + List expectedWorkflowIds = List.of(handle1.workflowId(), handle2.workflowId()); + assertTrue(expectedWorkflowIds.contains(localHandles.get(0).workflowId())); + assertTrue(expectedWorkflowIds.contains(localHandles.get(1).workflowId())); + + assertEquals(2, impl.counter.get()); + // Recovery sets back to enqueued. + // The enqueued run will get skipped (first run is still blocked) + assertEquals(WorkflowState.ENQUEUED, handle1.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, handle2.getStatus().status()); + assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); + + qs.unpause(); + impl.latch.countDown(); + assertEquals(0, handle1.getResult()); + assertEquals(1, handle2.getResult()); + assertEquals(2, handle3.getResult()); + assertEquals("local", handle3.getStatus().executorId()); + + assertTrue(DBUtils.queueEntriesAreCleanedUp(dataSource)); + } + + @Test + public void testListenQueue() throws Exception { + var config = dbosConfig.withListenQueue("queueOne"); + try (var dbos = new DBOS(config)) { + + ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); + dbos.launch(); + + var qs = DBOSTestAccess.getQueueService(dbos); + qs.setSpeedupForTest(); + dbos.registerQueue("queueOne", QueueOptions.empty()); + dbos.registerQueue("queueTwo", QueueOptions.empty()); + + var h2 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("two"), + new StartWorkflowOptions().withQueue("queueTwo")); + var h1 = + dbos.startWorkflow( + () -> serviceQ.simpleQWorkflow("one"), + new StartWorkflowOptions().withQueue("queueOne")); + + Thread.sleep(3000); + assertEquals("oneone", h1.getResult()); + assertEquals(WorkflowState.ENQUEUED, h2.getStatus().status()); + } + } + + private static void awaitCondition(BooleanSupplier condition) throws InterruptedException { + long deadline = System.currentTimeMillis() + 2000; + while (!condition.getAsBoolean()) { + if (System.currentTimeMillis() > deadline) + throw new AssertionError("Condition not met within 2s"); + Thread.sleep(50); + } + } } diff --git a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java b/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java deleted file mode 100644 index 27bd8eb4..00000000 --- a/transact/src/test/java/dev/dbos/transact/queue/QueuesTest.java +++ /dev/null @@ -1,683 +0,0 @@ -package dev.dbos.transact.queue; - -import static org.junit.jupiter.api.Assertions.assertArrayEquals; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNull; -import static org.junit.jupiter.api.Assertions.assertThrows; -import static org.junit.jupiter.api.Assertions.assertTrue; - -import dev.dbos.transact.Constants; -import dev.dbos.transact.DBOS; -import dev.dbos.transact.DBOSTestAccess; -import dev.dbos.transact.StartWorkflowOptions; -import dev.dbos.transact.config.DBOSConfig; -import dev.dbos.transact.json.SerializationUtil; -import dev.dbos.transact.utils.DBUtils; -import dev.dbos.transact.utils.PgContainer; -import dev.dbos.transact.utils.WorkflowStatusInternalBuilder; -import dev.dbos.transact.workflow.ListWorkflowsInput; -import dev.dbos.transact.workflow.Queue; -import dev.dbos.transact.workflow.QueueOptions; -import dev.dbos.transact.workflow.WorkflowHandle; -import dev.dbos.transact.workflow.WorkflowState; -import dev.dbos.transact.workflow.WorkflowStatus; - -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.time.Duration; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.ArrayList; -import java.util.List; - -import com.zaxxer.hikari.HikariDataSource; -import org.junit.jupiter.api.*; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - -public class QueuesTest { - - private static final Logger logger = LoggerFactory.getLogger(QueuesTest.class); - - @AutoClose final PgContainer pgContainer = new PgContainer(); - - DBOSConfig dbosConfig; - @AutoClose DBOS dbos; - @AutoClose HikariDataSource dataSource; - - @BeforeEach - void beforeEach() { - dbosConfig = pgContainer.dbosConfig(); - dbos = new DBOS(dbosConfig); - dataSource = pgContainer.dataSource(); - } - - // One dedicated in-memory queue test to guard against regressions on the - // static-queue path. All other tests below use database-backed queues. - @Test - public void testQueuedWorkflow() throws Exception { - - Queue firstQ = new Queue("firstQueue").withConcurrency(1).withWorkerConcurrency(1); - dbos.registerQueue(firstQ); - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - String id = "q1234"; - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("inputq"), new StartWorkflowOptions(id).withQueue(firstQ)); - - var handle = dbos.retrieveWorkflow(id); - assertEquals(id, handle.workflowId()); - String result = (String) handle.getResult(); - assertEquals("inputqinputq", result); - } - - @Test - public void testDedupeId() throws Exception { - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("firstQueue", QueueOptions.empty()); - - // pause queue service for test validation - qs.pause(); - - var options = new StartWorkflowOptions().withQueue("firstQueue"); - var dedupeId = "dedupeId"; - var h1 = - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("abc"), options.withDeduplicationId(dedupeId)); - var s1 = h1.getStatus(); - assertEquals(s1.queueName(), "firstQueue"); - assertEquals(s1.deduplicationId(), dedupeId); - - // enqueue with different dedupe ID should be fine - var dedupeId2 = "different-dedupeId"; - var h2 = - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("def"), options.withDeduplicationId(dedupeId2)); - var s2 = h2.getStatus(); - assertEquals(s2.queueName(), "firstQueue"); - assertEquals(s2.deduplicationId(), dedupeId2); - - // enqueue with no dedupe ID should be fine - var h3 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); - var s3 = h3.getStatus(); - assertEquals(s3.queueName(), "firstQueue"); - assertNull(s3.deduplicationId()); - - assertThrows( - RuntimeException.class, - () -> - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("jkl"), options.withDeduplicationId(dedupeId))); - - // enable queue service to run - qs.unpause(); - - // wait for initial workflow with initial dedupe ID to finish - h1.getResult(); - h2.getResult(); - h3.getResult(); - - var h4 = - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("jkl"), options.withDeduplicationId(dedupeId)); - h4.getResult(); - - var rows = DBUtils.getWorkflowRows(dataSource); - assertEquals(4, rows.size()); - - for (var row : rows) { - assertEquals(WorkflowState.SUCCESS.name(), row.status()); - assertEquals("firstQueue", row.queueName()); - assertNull(row.deduplicationId()); - } - } - - @Test - public void testDedupeIdWithDelay() throws Exception { - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("firstQueue", QueueOptions.empty()); - - qs.pause(); - - var dedupeId = "dedupeId"; - var options = new StartWorkflowOptions().withQueue("firstQueue").withDeduplicationId(dedupeId); - var h1 = - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("abc"), options.withDelay(Duration.ofHours(1))); - var s1 = h1.getStatus(); - assertEquals(WorkflowState.DELAYED, s1.status()); - assertEquals(dedupeId, s1.deduplicationId()); - - // Same dedupe ID should conflict even while DELAYED - assertThrows( - RuntimeException.class, - () -> dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("def"), options)); - - // Clear the delay and run - dbos.setWorkflowDelay(h1.workflowId(), Instant.now().minusSeconds(1)); - qs.unpause(); - h1.getResult(); - - // After completion the dedupe ID is released — re-enqueue should succeed - var h2 = dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("ghi"), options); - h2.getResult(); - } - - @Test - public void testPriority() throws Exception { - - ServiceQImpl impl = new ServiceQImpl(); - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, impl); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue( - "firstQueue", - QueueOptions.setPriorityEnabled(true).andConcurrency(1).andWorkerConcurrency(1)); - - qs.pause(); - - var o1 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(100); - var h1 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(100), o1); - - var o2 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(50); - var h2 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(50), o2); - - var o3 = new StartWorkflowOptions().withQueue("firstQueue").withPriority(10); - var h3 = dbos.startWorkflow(() -> serviceQ.priorityWorkflow(10), o3); - - qs.unpause(); - - h1.getResult(); - h2.getResult(); - h3.getResult(); - - assertEquals(3, impl.queue.size()); - assertEquals(10, impl.queue.remove()); - assertEquals(50, impl.queue.remove()); - assertEquals(100, impl.queue.remove()); - } - - @Test - public void testQueuedMultipleWorkflows() throws Exception { - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); - - qs.pause(); - Thread.sleep(2000); - - for (int i = 0; i < 5; i++) { - String id = "wfid" + i; - var input = "inputq" + i; - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow(input), - new StartWorkflowOptions(id).withQueue("firstQueue")); - } - - var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); - List wfs = dbos.listWorkflows(input); - - for (int i = 0; i < 5; i++) { - String id = "wfid" + i; - - assertEquals(id, wfs.get(i).workflowId()); - assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); - } - - qs.unpause(); - - for (int i = 0; i < 5; i++) { - String id = "wfid" + i; - - var handle = dbos.retrieveWorkflow(id); - assertEquals(id, handle.workflowId()); - String result = (String) handle.getResult(); - assertEquals("inputq" + i + "inputq" + i, result); - assertEquals(WorkflowState.SUCCESS, handle.getStatus().status()); - } - } - - @Test - void testListQueuedWorkflow() throws Exception { - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); - - qs.pause(); - - for (int i = 0; i < 5; i++) { - String id = "wfid" + i; - var input = "inputq" + i; - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow(input), - new StartWorkflowOptions(id).withQueue("firstQueue")); - Thread.sleep(100); - } - - var input = new ListWorkflowsInput().withQueuesOnly(true).withLoadInput(true); - List wfs = dbos.listWorkflows(input); - wfs.sort( - (a, b) -> { - return a.workflowId().compareTo(b.workflowId()); - }); - - for (int i = 0; i < 5; i++) { - String id = "wfid" + i; - - assertEquals(id, wfs.get(i).workflowId()); - assertEquals(WorkflowState.ENQUEUED, wfs.get(i).status()); - } - - wfs = dbos.listWorkflows(input.withQueueName("abc")); - assertEquals(0, wfs.size()); - - wfs = dbos.listWorkflows(input.withQueueName("firstQueue")); - assertEquals(5, wfs.size()); - - wfs = dbos.listWorkflows(input.withEndTime(Instant.now().minus(10, ChronoUnit.SECONDS))); - assertEquals(0, wfs.size()); - } - - @Test - public void multipleQueues() throws Exception { - - ServiceQ serviceQ1 = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - ServiceI serviceI = dbos.registerProxy(ServiceI.class, new ServiceIImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("firstQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); - dbos.registerQueue("secondQueue", QueueOptions.setConcurrency(1).andWorkerConcurrency(1)); - - String id1 = "firstQ1234"; - String id2 = "second1234"; - - var options1 = new StartWorkflowOptions(id1).withQueue("firstQueue"); - WorkflowHandle handle1 = - dbos.startWorkflow(() -> serviceQ1.simpleQWorkflow("firstinput"), options1); - - var options2 = new StartWorkflowOptions(id2).withQueue("secondQueue"); - WorkflowHandle handle2 = dbos.startWorkflow(() -> serviceI.workflowI(25), options2); - - assertEquals(id1, handle1.workflowId()); - String result = handle1.getResult(); - assertEquals("firstQueue", handle1.getStatus().queueName()); - assertEquals("firstinputfirstinput", result); - assertEquals(WorkflowState.SUCCESS, handle1.getStatus().status()); - - assertEquals(id2, handle2.workflowId()); - Integer result2 = (Integer) handle2.getResult(); - assertEquals("secondQueue", handle2.getStatus().queueName()); - assertEquals(50, result2); - assertEquals(WorkflowState.SUCCESS, handle2.getStatus().status()); - } - - @Test - public void testLimiter() throws Exception { - - int limit = 5; - double periodSec = 1.8; - Duration period = Duration.ofMillis((long) (periodSec * 1000)); - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue( - "limitQueue", - QueueOptions.setRateLimit(limit, period).andConcurrency(1).andWorkerConcurrency(1)); - Thread.sleep(1000); - - int numWaves = 3; - int numTasks = numWaves * limit; - List> handles = new ArrayList<>(); - List times = new ArrayList<>(); - - for (int i = 0; i < numTasks; i++) { - String id = "id" + i; - var options = new StartWorkflowOptions(id).withQueue("limitQueue"); - WorkflowHandle handle = - dbos.startWorkflow(() -> serviceQ.limitWorkflow("abc", "123"), options); - handles.add(handle); - } - - for (WorkflowHandle h : handles) { - double result = h.getResult(); - logger.info(String.valueOf(result)); - times.add(result); - } - - double waveTolerance = 1.0; - for (int wave = 0; wave < numWaves; wave++) { - for (int i = wave * limit; i < (wave + 1) * limit - 1; i++) { - double diff = times.get(i + 1) - times.get(i); - logger.info(String.format("Wave %d, Task %d-%d: Time diff %.3f", wave, i, i + 1, diff)); - assertTrue( - diff < waveTolerance, - String.format( - "Wave %d: Tasks %d and %d should start close together. Diff: %.3f", - wave, i, i + 1, diff)); - } - } - logger.info("Verified intra-wave timing."); - - double periodTolerance = 0.5; - for (int wave = 0; wave < numWaves - 1; wave++) { - double startOfNextWave = times.get(limit * (wave + 1)); - double startOfCurrentWave = times.get(limit * wave); - double gap = startOfNextWave - startOfCurrentWave; - logger.info(String.format("Gap between Wave %d and %d: %.3f", wave, wave + 1, gap)); - assertTrue( - gap > periodSec - periodTolerance, - String.format( - "Gap between wave %d and %d should be at least %.3f. Actual: %.3f", - wave, wave + 1, periodSec - periodTolerance, gap)); - assertTrue( - gap < periodSec + periodTolerance, - String.format( - "Gap between wave %d and %d should be at most %.3f. Actual: %.3f", - wave, wave + 1, periodSec + periodTolerance, gap)); - } - - for (WorkflowHandle h : handles) { - assertEquals(WorkflowState.SUCCESS, h.getStatus().status()); - } - } - - @Test - public void testWorkerConcurrency() throws Exception { - - dbos.launch(); - var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); - var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); - var queueService = DBOSTestAccess.getQueueService(dbos); - - dbos.registerQueue("QwithWCLimit", QueueOptions.setConcurrency(3).andWorkerConcurrency(2)); - Queue qwithWCLimit = dbos.findQueue("QwithWCLimit").get(); - - String executorId = dbosExecutor.executorId(); - String appVersion = dbosExecutor.appVersion(); - - queueService.close(); - while (!queueService.isStopped()) { - Thread.sleep(2000); - logger.info("Waiting for queueService to stop"); - } - - var serArgs = SerializationUtil.serializeValue(new Object[] {"ORD-12345"}, null, null); - var builder = - new WorkflowStatusInternalBuilder() - .workflowName("OrderProcessingWorkflow") - .className("com.example.workflows.OrderWorkflow") - .instanceName("prod-config") - .authenticatedUser("user123@example.com") - .assumedRole("admin") - .authenticatedRoles(new String[] {"admin", "operator"}) - .queueName("QwithWCLimit") - .executorId(executorId) - .appVersion(appVersion) - .appId("order-app-123") - .timeout(Duration.ofMillis(300000)) - .deadline(Instant.ofEpochMilli(System.currentTimeMillis() + 2400000)) - .priority(1) - .inputs(serArgs.serializedValue()); - - for (int i = 0; i < 4; i++) { - String wfid = "id" + i; - var status = builder.workflowId(wfid).deduplicationId("dedup" + i).build(); - systemDatabase.initWorkflowStatus(status, null, false, false); - } - - var readBack = systemDatabase.listWorkflows(new ListWorkflowsInput("id0")).get(0); - assertArrayEquals(new String[] {"admin", "operator"}, readBack.authenticatedRoles()); - - List idsToRun = - systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); - - assertEquals(2, idsToRun.size()); - - // run the same above 2 are in Pending. - // So no de queueing - idsToRun = - systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); - assertEquals(0, idsToRun.size()); - - // mark the first 2 as success - DBUtils.updateAllWorkflowStates( - dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); - - // next 2 get dequeued - idsToRun = - systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); - assertEquals(2, idsToRun.size()); - - DBUtils.updateAllWorkflowStates( - dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); - idsToRun = - systemDatabase.getAndStartQueuedWorkflows( - qwithWCLimit, Constants.DEFAULT_EXECUTORID, Constants.DEFAULT_APP_VERSION, null); - assertEquals(0, idsToRun.size()); - } - - @Test - public void testGlobalConcurrency() throws Exception { - - dbos.launch(); - var systemDatabase = DBOSTestAccess.getSystemDatabase(dbos); - var dbosExecutor = DBOSTestAccess.getDbosExecutor(dbos); - var queueService = DBOSTestAccess.getQueueService(dbos); - - dbos.registerQueue("QwithWCLimit", QueueOptions.setConcurrency(3).andWorkerConcurrency(2)); - Queue qwithWCLimit = dbos.findQueue("QwithWCLimit").get(); - - String executorId = dbosExecutor.executorId(); - String appVersion = dbosExecutor.appVersion(); - - queueService.close(); - while (!queueService.isStopped()) { - Thread.sleep(2000); - logger.info("Waiting for queueService to stop"); - } - - var builder = - new WorkflowStatusInternalBuilder() - .workflowName("OrderProcessingWorkflow") - .className("com.example.workflows.OrderWorkflow") - .instanceName("prod-config") - .authenticatedUser("user123@example.com") - .assumedRole("admin") - .authenticatedRoles(new String[] {"admin", "operator"}) - .queueName("QwithWCLimit") - .executorId(executorId) - .appVersion(appVersion) - .appId("order-app-123") - .timeout(Duration.ofMillis(300000)) - .deadline(Instant.ofEpochMilli(System.currentTimeMillis() + 2400000)) - .priority(1) - .inputs("{\"orderId\":\"ORD-12345\"}"); - - // executor1 - for (int i = 0; i < 2; i++) { - String wfid = "id" + i; - var status = builder.workflowId(wfid).deduplicationId("dedup" + i).build(); - systemDatabase.initWorkflowStatus(status, null, false, false); - } - - // executor2 - String executor2 = "remote"; - for (int i = 2; i < 5; i++) { - String wfid = "id" + i; - var status = - builder.workflowId(wfid).deduplicationId("dedup" + i).executorId(executor2).build(); - systemDatabase.initWorkflowStatus(status, null, false, false); - - DBUtils.setWorkflowState(dataSource, wfid, WorkflowState.PENDING.name()); - } - - List idsToRun = - systemDatabase.getAndStartQueuedWorkflows(qwithWCLimit, executorId, appVersion, null); - // 0 because global concurrency limit is reached - assertEquals(0, idsToRun.size()); - - DBUtils.updateAllWorkflowStates( - dataSource, WorkflowState.PENDING.name(), WorkflowState.SUCCESS.name()); - idsToRun = - systemDatabase.getAndStartQueuedWorkflows( - qwithWCLimit, - // executorId, - executor2, - appVersion, - null); - assertEquals(2, idsToRun.size()); - } - - @Test - public void testenQueueWF() throws Exception { - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("firstQueue", QueueOptions.empty()); - - String id = "q1234"; - - var option = new StartWorkflowOptions(id).withQueue("firstQueue"); - WorkflowHandle handle = - dbos.startWorkflow(() -> serviceQ.simpleQWorkflow("inputq"), option); - - assertEquals(id, handle.workflowId()); - String result = handle.getResult(); - assertEquals("inputqinputq", result); - } - - @Test - public void testQueueConcurrencyUnderRecovery() throws Exception { - ConcurrencyTestServiceImpl impl = new ConcurrencyTestServiceImpl(); - ConcurrencyTestService service = dbos.registerProxy(ConcurrencyTestService.class, impl); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("test_queue", QueueOptions.setConcurrency(2)); - - var opt1 = new StartWorkflowOptions("wf1").withQueue("test_queue"); - var handle1 = dbos.startWorkflow(() -> service.blockedWorkflow(0), opt1); - - var opt2 = new StartWorkflowOptions("wf2").withQueue("test_queue"); - var handle2 = dbos.startWorkflow(() -> service.blockedWorkflow(1), opt2); - - var opt3 = new StartWorkflowOptions("wf3").withQueue("test_queue"); - var handle3 = dbos.startWorkflow(() -> service.noopWorkflow(2), opt3); - - // each call to blockedWorkflow releases the semaphore once, - // so block waiting on both calls to release - impl.wfSemaphore.acquire(2); - - assertEquals(2, impl.counter.get()); - assertEquals(WorkflowState.PENDING, handle1.getStatus().status()); - assertEquals(WorkflowState.PENDING, handle2.getStatus().status()); - assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); - - // update WF3 to appear as if it's from a different executor - String sql = - "UPDATE dbos.workflow_status SET status = ?, executor_id = ? where workflow_uuid = ?;"; - - try (Connection connection = DBUtils.getConnection(dbosConfig); - PreparedStatement pstmt = connection.prepareStatement(sql)) { - - pstmt.setString(1, WorkflowState.PENDING.name()); - pstmt.setString(2, "other"); - pstmt.setString(3, opt3.workflowId()); - - // Execute the update and get the number of rows affected - int rowsAffected = pstmt.executeUpdate(); - assertEquals(1, rowsAffected); - } - - var executor = DBOSTestAccess.getDbosExecutor(dbos); - List> otherHandles = executor.recoverPendingWorkflows(List.of("other")); - assertEquals(WorkflowState.PENDING, handle1.getStatus().status()); - assertEquals(WorkflowState.PENDING, handle2.getStatus().status()); - assertEquals(1, otherHandles.size()); - assertEquals(otherHandles.get(0).workflowId(), handle3.workflowId()); - assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); - - // Pause the listener before recovery so it can't race the ENQUEUED status checks below. - qs.pause(); - List> localHandles = executor.recoverPendingWorkflows(List.of("local")); - assertEquals(2, localHandles.size()); - List expectedWorkflowIds = List.of(handle1.workflowId(), handle2.workflowId()); - assertTrue(expectedWorkflowIds.contains(localHandles.get(0).workflowId())); - assertTrue(expectedWorkflowIds.contains(localHandles.get(1).workflowId())); - - assertEquals(2, impl.counter.get()); - // Recovery sets back to enqueued. - // The enqueued run will get skipped (first run is still blocked) - assertEquals(WorkflowState.ENQUEUED, handle1.getStatus().status()); - assertEquals(WorkflowState.ENQUEUED, handle2.getStatus().status()); - assertEquals(WorkflowState.ENQUEUED, handle3.getStatus().status()); - - qs.unpause(); - impl.latch.countDown(); - assertEquals(0, handle1.getResult()); - assertEquals(1, handle2.getResult()); - assertEquals(2, handle3.getResult()); - assertEquals("local", handle3.getStatus().executorId()); - - assertTrue(DBUtils.queueEntriesAreCleanedUp(dataSource)); - } - - @Test - public void testListenQueue() throws Exception { - var config = dbosConfig.withListenQueue("queueOne"); - try (var dbos = new DBOS(config)) { - - ServiceQ serviceQ = dbos.registerProxy(ServiceQ.class, new ServiceQImpl()); - dbos.launch(); - - var qs = DBOSTestAccess.getQueueService(dbos); - qs.setSpeedupForTest(); - dbos.registerQueue("queueOne", QueueOptions.empty()); - dbos.registerQueue("queueTwo", QueueOptions.empty()); - - var h2 = - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("two"), - new StartWorkflowOptions().withQueue("queueTwo")); - var h1 = - dbos.startWorkflow( - () -> serviceQ.simpleQWorkflow("one"), - new StartWorkflowOptions().withQueue("queueOne")); - - Thread.sleep(3000); - assertEquals("oneone", h1.getResult()); - assertEquals(WorkflowState.ENQUEUED, h2.getStatus().status()); - } - } -}