From ee8a962e8baa571b8260432f57c934c6cfce47b2 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 17:56:25 +0300 Subject: [PATCH 01/29] =?UTF-8?q?Add=20Debouncer=20=E2=80=94=20coalesce=20?= =?UTF-8?q?rapid=20workflow=20calls=20into=20one=20execution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements a debounce mechanism for DBOS workflows analogous to dbos-transact-py _debouncer.py. Multiple calls with the same key within a period are collapsed into a single user-workflow execution that runs with the most recently supplied arguments. Architecture: - DebouncerServiceImpl: internal @Workflow that runs a recv-loop, absorbing messages until the debounce period times out or the absolute debounceTimeout elapses, then starts the user workflow. - Debouncer: public fluent API. Enqueues the service workflow on _dbos_internal_queue with a deduplicationId derived from (workflowName, debounceKey). On DBOSQueueDuplicatedException, forwards a message to the running debouncer and waits for an ack. - DBOSExecutor.captureInvocation(): extracted from startWorkflow so Debouncer can capture a lambda's workflow call without executing it. - Auto-registration of DebouncerService in DBOS constructor so users need no boilerplate setup. - Internal system workflows filtered from getRegisteredWorkflows / getRegisteredWorkflowInstances to keep public counts clean. Usage: var handle = dbos.debouncer() .withDebounceTimeout(Duration.ofMinutes(5)) .debounce("key", Duration.ofSeconds(2), () -> svc.process(arg)); String result = handle.getResult(); Tests: 6 integration tests via Testcontainers Postgres covering single-call, multi-call coalescing, absolute timeout, independent keys, concurrent callers, and queue-based user workflow. --- .../java/dev/dbos/transact/Constants.java | 3 + .../src/main/java/dev/dbos/transact/DBOS.java | 22 ++ .../dbos/transact/execution/DBOSExecutor.java | 63 ++--- .../transact/internal/DBOSIntegration.java | 75 +++++- .../dev/dbos/transact/workflow/Debouncer.java | 236 ++++++++++++++++++ .../internal/DebouncerContextOptions.java | 18 ++ .../workflow/internal/DebouncerMessage.java | 13 + .../workflow/internal/DebouncerOptions.java | 17 ++ .../workflow/internal/DebouncerService.java | 21 ++ .../internal/DebouncerServiceImpl.java | 106 ++++++++ .../dbos/transact/workflow/DebouncerTest.java | 200 +++++++++++++++ 11 files changed, 730 insertions(+), 44 deletions(-) create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java create mode 100644 transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java diff --git a/transact/src/main/java/dev/dbos/transact/Constants.java b/transact/src/main/java/dev/dbos/transact/Constants.java index f510d2c70..403e62ffb 100644 --- a/transact/src/main/java/dev/dbos/transact/Constants.java +++ b/transact/src/main/java/dev/dbos/transact/Constants.java @@ -16,6 +16,9 @@ public class Constants { public static final String DBOS_INTERNAL_QUEUE = "_dbos_internal_queue"; + public static final String DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"; + public static final String DEBOUNCER_TOPIC = "_dbos_debouncer_topic"; + public static final String SYSTEM_JDBC_URL_ENV_VAR = "DBOS_SYSTEM_JDBC_URL"; public static final int DEFAULT_MAX_RECOVERY_ATTEMPTS = 100; diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 6645266e6..a1cb0eb07 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.Debouncer; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; @@ -25,6 +26,8 @@ import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowSchedule; import dev.dbos.transact.workflow.WorkflowStatus; +import dev.dbos.transact.workflow.internal.DebouncerService; +import dev.dbos.transact.workflow.internal.DebouncerServiceImpl; import java.io.IOException; import java.io.InputStream; @@ -62,6 +65,7 @@ public class DBOS implements AutoCloseable { private final DBOSConfig config; private final AtomicReference dbosExecutor = new AtomicReference<>(); private final DBOSIntegration integration; + private final DebouncerService debouncerProxy; private AlertHandler alertHandler; @@ -84,6 +88,9 @@ public DBOS(@NonNull DBOSConfig config) { this.integration = new DBOSIntegration( this.config, this.workflowRegistry, dbosExecutor::get, this::registerLifecycleListener); + // Register the built-in debouncer service workflow so callers can use Debouncer without + // having to declare and wire the service themselves. + this.debouncerProxy = registerProxy(DebouncerService.class, new DebouncerServiceImpl(this)); } /** @@ -389,6 +396,21 @@ public void sleep(@NonNull Duration duration) { return startWorkflow(runnable, null); } + /** + * Build a {@link Debouncer} that consolidates a series of calls on the same key into one + * execution of the targeted workflow using the most recent arguments. + * + *

The returned debouncer is immutable; configuration helpers like {@link + * Debouncer#withQueue(String)} and {@link Debouncer#withDebounceTimeout(java.time.Duration)} + * return new instances. + * + * @param the return type of the debounced workflow (used only for type inference) + * @return a fresh debouncer bound to this DBOS instance + */ + public @NonNull Debouncer debouncer() { + return new Debouncer<>(this, debouncerProxy); + } + /** * Returns the DBOS integration APIs for use by specialized integrations such as AOP aspects and * event listeners. 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 92149f724..a787d7344 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -75,7 +75,6 @@ import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicReference; import java.util.function.Consumer; -import java.util.function.Function; import java.util.function.Supplier; import java.util.stream.Collectors; @@ -92,7 +91,7 @@ public class DBOSExecutor implements AutoCloseable { // Invocation and hookHolder are used by startWorkflow to capture // workflow information w/o executing workflow function - record Invocation( + public record Invocation( DBOSExecutor executor, String workflowName, String className, @@ -1205,6 +1204,36 @@ public Object runWorkflow( } } + /** + * Capture the workflow invocation triggered by the supplied lambda without executing the + * workflow. The lambda must call exactly one @Workflow method on a registered proxy on this + * executor. + */ + public Invocation captureInvocation(ThrowingSupplier wfLambda) { + AtomicReference capturedInvocation = new AtomicReference<>(); + DBOSExecutor.hookHolder.set( + (invocation) -> { + if (!capturedInvocation.compareAndSet(null, invocation)) { + throw new RuntimeException("Only one @Workflow can be called in the captured lambda"); + } + }); + try { + wfLambda.execute(); + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + DBOSExecutor.hookHolder.remove(); + } + var invocation = + Objects.requireNonNull( + capturedInvocation.get(), "The lambda must call exactly one @Workflow"); + if (invocation.executor() != this) { + throw new IllegalStateException( + "The @Workflow method must be called on the DBOS instance passed to the lambda"); + } + return invocation; + } + // execute a workflow via startWorkflow public WorkflowHandle startWorkflow( ThrowingSupplier wfLambda, StartWorkflowOptions options) { @@ -1217,35 +1246,7 @@ public WorkflowHandle startWorkflow( throw new IllegalArgumentException("explicit timeout and deadline cannot both be set"); } - // set the invocation hook holder and invoke the lambda to retrieve the invocation information - Function, Invocation> invocationSupplier = - (lambda) -> { - AtomicReference capturedInvocation = new AtomicReference<>(); - DBOSExecutor.hookHolder.set( - (invocation) -> { - if (!capturedInvocation.compareAndSet(null, invocation)) { - throw new RuntimeException( - "Only one @Workflow can be called in the startWorkflow lambda"); - } - }); - - try { - lambda.execute(); - } catch (Exception e) { - throw new RuntimeException(e); - } finally { - DBOSExecutor.hookHolder.remove(); - } - - return Objects.requireNonNull( - capturedInvocation.get(), "The startWorkflow lambda must call exactly one @Workflow"); - }; - - var invocation = invocationSupplier.apply(wfLambda); - if (invocation.executor() != this) { - throw new IllegalStateException( - "The @Workflow method must be called on the DBOS instance passed to the startWorkflow lambda"); - } + var invocation = captureInvocation(wfLambda); var workflow = getRegisteredWorkflow( invocation.workflowName(), invocation.className(), invocation.instanceName()) diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 4abb6b1f1..040947b07 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -1,5 +1,6 @@ package dev.dbos.transact.internal; +import dev.dbos.transact.Constants; import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.config.DBOSConfig; import dev.dbos.transact.database.ExternalState; @@ -7,17 +8,19 @@ import dev.dbos.transact.execution.DBOSLifecycleListener; import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.RegisteredWorkflowInstance; +import dev.dbos.transact.execution.ThrowingSupplier; import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowHandle; +import dev.dbos.transact.workflow.internal.DebouncerService; import java.lang.reflect.Method; import java.util.Collection; -import java.util.Collections; import java.util.Objects; import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; +import java.util.stream.Collectors; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -153,6 +156,36 @@ public RegisteredWorkflow registerWorkflow( serializationStrategy); } + /** + * Captured information about a workflow invocation: the workflow name, declaring class name, + * optional instance name, and positional arguments. + */ + public record CapturedInvocation( + @NonNull String workflowName, + @NonNull String className, + @Nullable String instanceName, + @NonNull Object[] args) {} + + /** + * Capture the workflow invocation triggered by the supplied lambda without executing the + * workflow. The lambda must call exactly one {@code @Workflow} method on a registered proxy. This + * is intended for infrastructure that needs to defer a workflow start (for example, the + * debouncer). + * + * @param wfLambda lambda that invokes exactly one workflow method on a registered proxy + * @return the captured workflow name, class name, instance name, and arguments + * @throws IllegalStateException if DBOS has not been launched + */ + public CapturedInvocation captureInvocation( + @NonNull ThrowingSupplier wfLambda) { + var invocation = executor("captureInvocation").captureInvocation(wfLambda); + return new CapturedInvocation( + invocation.workflowName(), + invocation.className(), + invocation.instanceName(), + invocation.args()); + } + /** * Start or enqueue a workflow by its {@link RegisteredWorkflow} registration. Intended for use by * event listeners and other infrastructure that dispatches workflows by registration rather than @@ -192,30 +225,46 @@ public Object runWorkflow( return executor("runWorkflow").runWorkflow(target, instanceName, method, args, wfTag); } + private static boolean isInternalWorkflow(RegisteredWorkflow wf) { + return Constants.DEBOUNCER_WORKFLOW_NAME.equals(wf.workflowName()); + } + + private static boolean isInternalInstance(RegisteredWorkflowInstance inst) { + return inst.target() instanceof DebouncerService; + } + /** - * Get all workflows registered with DBOS. + * Get all user-registered workflows. Internal/system workflows registered by DBOS itself (for + * example, the debouncer service workflow) are excluded. * - * @return list of all registered workflow methods + * @return list of all user-registered workflow methods */ public @NonNull Collection getRegisteredWorkflows() { var executor = executorSupplier.get(); - if (executor != null) { - return executor.getRegisteredWorkflows(); - } - return Collections.unmodifiableCollection(workflowRegistry.getWorkflowSnapshot().values()); + Collection all = + executor != null + ? executor.getRegisteredWorkflows() + : workflowRegistry.getWorkflowSnapshot().values(); + return all.stream() + .filter(wf -> !isInternalWorkflow(wf)) + .collect(Collectors.toUnmodifiableList()); } /** - * Get all workflow instances registered with DBOS. + * Get all user-registered workflow instances. Internal/system instances registered by DBOS itself + * (for example, the debouncer service) are excluded. * - * @return list of all class instances containing registered workflow methods + * @return list of all user-registered class instances containing workflow methods */ public @NonNull Collection getRegisteredWorkflowInstances() { var executor = executorSupplier.get(); - if (executor != null) { - return executor.getRegisteredWorkflowInstances(); - } - return Collections.unmodifiableCollection(workflowRegistry.getInstanceSnapshot().values()); + Collection all = + executor != null + ? executor.getRegisteredWorkflowInstances() + : workflowRegistry.getInstanceSnapshot().values(); + return all.stream() + .filter(inst -> !isInternalInstance(inst)) + .collect(Collectors.toUnmodifiableList()); } /** diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java new file mode 100644 index 000000000..52f4544b4 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -0,0 +1,236 @@ +package dev.dbos.transact.workflow; + +import dev.dbos.transact.Constants; +import dev.dbos.transact.DBOS; +import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; +import dev.dbos.transact.execution.ThrowingRunnable; +import dev.dbos.transact.execution.ThrowingSupplier; +import dev.dbos.transact.internal.DBOSIntegration; +import dev.dbos.transact.workflow.internal.DebouncerContextOptions; +import dev.dbos.transact.workflow.internal.DebouncerMessage; +import dev.dbos.transact.workflow.internal.DebouncerOptions; +import dev.dbos.transact.workflow.internal.DebouncerService; + +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Debounces a series of workflow invocations on the same key into a single execution that uses the + * most recently supplied arguments. + * + *

Each unique {@code debounceKey} maintains its own debouncer service workflow that absorbs + * incoming calls. The service workflow starts the actual user workflow after either {@code + * debouncePeriod} has elapsed since the last incoming call or the absolute {@code debounceTimeout} + * has expired. + * + *

The returned {@link WorkflowHandle} points to the user workflow that will eventually run with + * the latest arguments; polling it for {@code getResult()} waits for that workflow's outcome. + * + *

Example

+ * + *
{@code
+ * var dbos = new DBOS(config);
+ * var svc = dbos.registerProxy(MyService.class, new MyServiceImpl());
+ * dbos.launch();
+ *
+ * var debouncer = dbos.debouncer()
+ *     .withDebounceTimeout(Duration.ofMinutes(5));
+ *
+ * WorkflowHandle handle = debouncer.debounce(
+ *     "user-42",
+ *     Duration.ofSeconds(2),
+ *     () -> svc.process("payload"));
+ * String result = handle.getResult();
+ * }
+ * + * @param return type of the debounced workflow + */ +public final class Debouncer { + + private static final Logger logger = LoggerFactory.getLogger(Debouncer.class); + + /** + * How long to wait for the debouncer service workflow to acknowledge a forwarded message before + * retrying. + */ + private static final Duration ACK_TIMEOUT = Duration.ofSeconds(1); + + private final DBOS dbos; + private final DebouncerService debouncerProxy; + private final @Nullable String queueName; + private final @Nullable Duration debounceTimeout; + + public Debouncer(@NonNull DBOS dbos, @NonNull DebouncerService debouncerProxy) { + this(dbos, debouncerProxy, null, null); + } + + private Debouncer( + DBOS dbos, + DebouncerService debouncerProxy, + @Nullable String queueName, + @Nullable Duration debounceTimeout) { + this.dbos = Objects.requireNonNull(dbos, "dbos must not be null"); + this.debouncerProxy = Objects.requireNonNull(debouncerProxy, "debouncerProxy must not be null"); + this.queueName = queueName; + this.debounceTimeout = debounceTimeout; + } + + /** + * Set the queue that the user workflow will be enqueued on when the debounce period elapses. + * {@code null} starts the user workflow directly (not enqueued). + */ + public @NonNull Debouncer withQueue(@Nullable String queueName) { + return new Debouncer<>(dbos, debouncerProxy, queueName, debounceTimeout); + } + + /** See {@link #withQueue(String)}. */ + public @NonNull Debouncer withQueue(@NonNull Queue queue) { + return withQueue(queue.name()); + } + + /** + * Set an absolute cap on how long a debouncer for a single key may keep absorbing calls. After + * this duration elapses from the first call, the user workflow is started even if more calls keep + * arriving. + */ + public @NonNull Debouncer withDebounceTimeout(@Nullable Duration debounceTimeout) { + return new Debouncer<>(dbos, debouncerProxy, queueName, debounceTimeout); + } + + /** + * Debounce a workflow with no return value. The supplier's return value is ignored — pass {@code + * null} when no value is available. + * + * @param debounceKey key that groups concurrent calls; calls with the same key are coalesced + * @param debouncePeriod inactivity window before the user workflow runs; each call resets it + * @param wfLambda lambda calling exactly one {@code @Workflow} method + * @return handle to the future user workflow + */ + public @NonNull WorkflowHandle debounceVoid( + @NonNull String debounceKey, + @NonNull Duration debouncePeriod, + @NonNull ThrowingRunnable wfLambda) { + return debounceInternal( + debounceKey, + debouncePeriod, + () -> { + wfLambda.execute(); + return null; + }); + } + + /** + * Debounce a workflow with a return value. + * + * @param debounceKey key that groups concurrent calls; calls with the same key are coalesced + * @param debouncePeriod inactivity window before the user workflow runs; each call resets it + * @param wfLambda lambda calling exactly one {@code @Workflow} method + * @return handle to the future user workflow + */ + public @NonNull WorkflowHandle debounce( + @NonNull String debounceKey, + @NonNull Duration debouncePeriod, + @NonNull ThrowingSupplier wfLambda) { + return debounceInternal(debounceKey, debouncePeriod, wfLambda); + } + + private WorkflowHandle debounceInternal( + @NonNull String debounceKey, + @NonNull Duration debouncePeriod, + @NonNull ThrowingSupplier wfLambda) { + + Objects.requireNonNull(debounceKey, "debounceKey must not be null"); + Objects.requireNonNull(debouncePeriod, "debouncePeriod must not be null"); + Objects.requireNonNull(wfLambda, "wfLambda must not be null"); + if (debouncePeriod.isNegative() || debouncePeriod.isZero()) { + throw new IllegalArgumentException("debouncePeriod must be a positive non-zero duration"); + } + + DBOSIntegration.CapturedInvocation invocation = dbos.integration().captureInvocation(wfLambda); + + String userWorkflowId = UUID.randomUUID().toString(); + String messageId = UUID.randomUUID().toString(); + String deduplicationId = invocation.workflowName() + "-" + debounceKey; + long periodMs = debouncePeriod.toMillis(); + + DebouncerOptions options = + new DebouncerOptions( + invocation.workflowName(), + invocation.className(), + invocation.instanceName(), + queueName, + debounceTimeout == null ? null : debounceTimeout.toMillis()); + DebouncerContextOptions ctx = + new DebouncerContextOptions(userWorkflowId, null, null, null, null); + DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), periodMs); + + while (true) { + try { + var startOpts = + new StartWorkflowOptions() + .withQueue(Constants.DBOS_INTERNAL_QUEUE) + .withDeduplicationId(deduplicationId); + dbos.startWorkflow( + () -> debouncerProxy.debouncerWorkflow(options, ctx, initial), startOpts); + // Successfully enqueued a fresh debouncer for this key. + return dbos.retrieveWorkflow(userWorkflowId); + } catch (DBOSQueueDuplicatedException dup) { + // A debouncer for this key is already running. Forward the latest args to it. + String existingDebouncerId = lookupExistingDebouncerId(deduplicationId); + if (existingDebouncerId == null) { + // The existing debouncer finished between the enqueue attempt and now. Retry from + // scratch — the next enqueue should succeed. + logger.debug( + "Debouncer for dedupId {} not found after conflict; retrying", deduplicationId); + continue; + } + DebouncerMessage msg = new DebouncerMessage(messageId, invocation.args(), periodMs); + dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC); + + // Wait for the debouncer to acknowledge receipt. If the debouncer exited before + // processing this message, no ack arrives — start over. + var ack = dbos.getEvent(existingDebouncerId, messageId, ACK_TIMEOUT); + if (ack.isEmpty()) { + logger.debug( + "Debouncer {} did not ack message {}; retrying", existingDebouncerId, messageId); + continue; + } + // The existing debouncer absorbed our call. Read the pre-assigned user workflow id + // from its persisted inputs and return a handle to it. + var status = dbos.getWorkflowStatus(existingDebouncerId).orElse(null); + if (status == null || status.input() == null) { + logger.debug("Debouncer {} status unavailable; retrying", existingDebouncerId); + continue; + } + Object[] dedupInputs = status.input(); + if (dedupInputs.length < 2 || !(dedupInputs[1] instanceof DebouncerContextOptions dco)) { + throw new IllegalStateException( + "Unexpected debouncer workflow inputs for " + existingDebouncerId); + } + return dbos.retrieveWorkflow(dco.userWorkflowId()); + } + } + } + + private @Nullable String lookupExistingDebouncerId(String deduplicationId) { + var input = + new ListWorkflowsInput() + .withQueueName(Constants.DBOS_INTERNAL_QUEUE) + .withQueuesOnly(true) + .withWorkflowName(Constants.DEBOUNCER_WORKFLOW_NAME) + .withLoadInput(false); + for (var s : dbos.listWorkflows(input)) { + if (deduplicationId.equals(s.deduplicationId())) { + return s.workflowId(); + } + } + return null; + } +} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java new file mode 100644 index 000000000..708de9722 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java @@ -0,0 +1,18 @@ +package dev.dbos.transact.workflow.internal; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Context captured from the caller of {@code Debouncer.debounce()} and forwarded to the user + * workflow when it is eventually started. The {@code userWorkflowId} is pre-assigned by the caller + * so that the caller can return a handle pointing to the future workflow. + * + *

Not part of the public API. + */ +public record DebouncerContextOptions( + @NonNull String userWorkflowId, + @Nullable String deduplicationId, + @Nullable Integer priority, + @Nullable String appVersion, + @Nullable Long workflowTimeoutMs) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java new file mode 100644 index 000000000..84abc9a3a --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java @@ -0,0 +1,13 @@ +package dev.dbos.transact.workflow.internal; + +import org.jspecify.annotations.NonNull; + +/** + * Message sent from a {@code Debouncer} caller to the debouncer service workflow each time the + * debounce key fires. The debouncer service workflow uses the most recently received message's args + * when it eventually starts the user workflow. + * + *

Not part of the public API — the debouncer infrastructure consumes this directly. + */ +public record DebouncerMessage( + @NonNull String messageId, @NonNull Object[] args, long debouncePeriodMs) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java new file mode 100644 index 000000000..68805fbd0 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java @@ -0,0 +1,17 @@ +package dev.dbos.transact.workflow.internal; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; + +/** + * Inputs to the debouncer service workflow that identify the user workflow to be eventually started + * and the optional absolute timeout cap. + * + *

Not part of the public API. + */ +public record DebouncerOptions( + @NonNull String workflowName, + @NonNull String className, + @Nullable String instanceName, + @Nullable String queueName, + @Nullable Long debounceTimeoutMs) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java new file mode 100644 index 000000000..0632a88d7 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java @@ -0,0 +1,21 @@ +package dev.dbos.transact.workflow.internal; + +/** + * Internal interface for the debouncer service workflow. Registered automatically by DBOS during + * construction so users do not need to declare it. + * + *

Not part of the public API. + */ +public interface DebouncerService { + + /** + * The debouncer service workflow. + * + * @param options identifies the user workflow to start and the absolute timeout + * @param ctx caller context forwarded to the user workflow + * @param initial initial debounce message from the first caller + * @return the user workflow id (the same value carried in {@code ctx.userWorkflowId()}) + */ + String debouncerWorkflow( + DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial); +} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java new file mode 100644 index 000000000..f15ef94db --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -0,0 +1,106 @@ +package dev.dbos.transact.workflow.internal; + +import dev.dbos.transact.Constants; +import dev.dbos.transact.DBOS; +import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.workflow.Workflow; + +import java.time.Duration; +import java.time.Instant; +import java.util.Optional; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Implementation of the debouncer service workflow. Holds a reference to the {@link DBOS} instance + * to call durable primitives (recv, setEvent, runStep) and to start the user workflow when the + * debounce period elapses. + * + *

Auto-registered by DBOS during construction. + */ +public class DebouncerServiceImpl implements DebouncerService { + + private static final Logger logger = LoggerFactory.getLogger(DebouncerServiceImpl.class); + + private final DBOS dbos; + + public DebouncerServiceImpl(DBOS dbos) { + this.dbos = dbos; + } + + @Override + @Workflow(name = Constants.DEBOUNCER_WORKFLOW_NAME) + public String debouncerWorkflow( + DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial) { + + // Record the absolute deadline once as a durable step. On recovery this returns the same + // value so the loop's exit condition is replay-stable across crashes. + long deadlineEpochMs = + dbos.runStep( + () -> { + if (options.debounceTimeoutMs() == null) { + return Long.MAX_VALUE; + } + return Instant.now().toEpochMilli() + options.debounceTimeoutMs(); + }, + "DBOS.debouncerComputeDeadline"); + + Object[] latestArgs = initial.args(); + long debouncePeriodMs = initial.debouncePeriodMs(); + + while (true) { + long now = dbos.runStep(() -> Instant.now().toEpochMilli(), "DBOS.debouncerNow"); + if (now >= deadlineEpochMs) { + break; + } + long timeUntilDeadlineMs = Math.max(deadlineEpochMs - now, 0); + long waitMs = Math.min(debouncePeriodMs, timeUntilDeadlineMs); + + Optional msg = + dbos.recv(Constants.DEBOUNCER_TOPIC, Duration.ofMillis(waitMs)); + if (msg.isEmpty()) { + // Period elapsed with no new message — fire. + break; + } + DebouncerMessage next = msg.get(); + latestArgs = next.args(); + debouncePeriodMs = next.debouncePeriodMs(); + // Acknowledge receipt so the sender knows the message was consumed by this loop iteration. + dbos.setEvent(next.messageId(), next.messageId()); + } + + var optWorkflow = + dbos.integration() + .getRegisteredWorkflow( + options.workflowName(), + options.className(), + options.instanceName() == null ? "" : options.instanceName()); + if (optWorkflow.isEmpty()) { + throw new IllegalStateException( + "Debouncer cannot find registered user workflow: " + + options.workflowName() + + " / " + + options.className() + + (options.instanceName() == null ? "" : " / " + options.instanceName())); + } + + var startOpts = + new StartWorkflowOptions() + .withWorkflowId(ctx.userWorkflowId()) + .withQueue(options.queueName()) + .withDeduplicationId(ctx.deduplicationId()) + .withPriority(ctx.priority()) + .withAppVersion(ctx.appVersion()); + if (ctx.workflowTimeoutMs() != null) { + startOpts = startOpts.withTimeout(Duration.ofMillis(ctx.workflowTimeoutMs())); + } + + logger.debug( + "Debouncer starting user workflow {} (id={})", + options.workflowName(), + ctx.userWorkflowId()); + dbos.integration().startRegisteredWorkflow(optWorkflow.orElseThrow(), latestArgs, startOpts); + return ctx.userWorkflowId(); + } +} diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java new file mode 100644 index 000000000..aaa2069c8 --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -0,0 +1,200 @@ +package dev.dbos.transact.workflow; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import dev.dbos.transact.DBOS; +import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.utils.PgContainer; + +import java.time.Duration; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; + +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +public class DebouncerTest { + + @AutoClose final PgContainer pgContainer = new PgContainer(); + + DBOSConfig dbosConfig; + @AutoClose DBOS dbos; + + // Per-instance counters so parallel test methods do not interfere. + public interface DebouncedService { + String process(String input); + + int callCount(); + + java.util.List callArgs(); + } + + public static class DebouncedServiceImpl implements DebouncedService { + private final AtomicInteger callCount = new AtomicInteger(); + private final ConcurrentLinkedQueue callArgs = new ConcurrentLinkedQueue<>(); + + @Override + @Workflow + public String process(String input) { + callCount.incrementAndGet(); + callArgs.add(input); + return "result:" + input; + } + + @Override + public int callCount() { + return callCount.get(); + } + + @Override + public java.util.List callArgs() { + return java.util.List.copyOf(callArgs); + } + } + + DebouncedServiceImpl serviceImpl; + + @BeforeEach + void beforeEach() { + dbosConfig = pgContainer.dbosConfig(); + dbos = new DBOS(dbosConfig); + serviceImpl = new DebouncedServiceImpl(); + } + + @Test + public void singleCallFiresOnce() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var handle = + dbos.debouncer().debounce("user-1", Duration.ofSeconds(1), () -> svc.process("v1")); + String result = handle.getResult(); + assertEquals("result:v1", result); + assertEquals(1, serviceImpl.callCount()); + assertEquals(List.of("v1"), serviceImpl.callArgs()); + } + + @Test + public void multipleCallsCoalesceToLatestArgs() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var debouncer = dbos.debouncer(); + var h1 = debouncer.debounce("user-2", Duration.ofMillis(800), () -> svc.process("v1")); + Thread.sleep(200); + var h2 = debouncer.debounce("user-2", Duration.ofMillis(800), () -> svc.process("v2")); + Thread.sleep(200); + var h3 = debouncer.debounce("user-2", Duration.ofMillis(800), () -> svc.process("v3")); + + String result = h3.getResult(); + assertEquals("result:v3", result); + // The three handles all point to the same final user workflow. + assertEquals(h1.workflowId(), h2.workflowId()); + assertEquals(h2.workflowId(), h3.workflowId()); + assertEquals(1, serviceImpl.callCount()); + assertEquals(List.of("v3"), serviceImpl.callArgs()); + } + + @Test + public void absoluteTimeoutFiresEvenIfCallsKeepArriving() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var debouncer = dbos.debouncer().withDebounceTimeout(Duration.ofMillis(1500)); + + var first = debouncer.debounce("user-3", Duration.ofMillis(800), () -> svc.process("v1")); + String firstId = first.workflowId(); + + // Keep extending the period — the absolute timeout should still kick in. + long deadline = System.currentTimeMillis() + 3000; + while (System.currentTimeMillis() < deadline && serviceImpl.callCount() == 0) { + debouncer.debounce("user-3", Duration.ofMillis(800), () -> svc.process("vN")); + Thread.sleep(150); + } + + String result = first.getResult(); + assertTrue(result.startsWith("result:")); + assertEquals(1, serviceImpl.callCount()); + assertEquals(firstId, first.workflowId()); + } + + @Test + public void differentKeysFireIndependently() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var debouncer = dbos.debouncer(); + var hA = debouncer.debounce("key-A", Duration.ofMillis(500), () -> svc.process("A")); + var hB = debouncer.debounce("key-B", Duration.ofMillis(500), () -> svc.process("B")); + + assertNotEquals(hA.workflowId(), hB.workflowId()); + assertEquals("result:A", hA.getResult()); + assertEquals("result:B", hB.getResult()); + assertEquals(2, serviceImpl.callCount()); + } + + @Test + public void concurrentCallsCoalesceSafely() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var debouncer = dbos.debouncer(); + int n = 8; + var pool = Executors.newFixedThreadPool(n); + try { + var ready = new CountDownLatch(n); + var go = new CountDownLatch(1); + var results = new ConcurrentLinkedQueue(); + for (int i = 0; i < n; i++) { + final String arg = "v" + i; + pool.submit( + () -> { + ready.countDown(); + go.await(); + var h = + debouncer.debounce("user-conc", Duration.ofMillis(600), () -> svc.process(arg)); + results.add(h.workflowId()); + return null; + }); + } + ready.await(5, TimeUnit.SECONDS); + go.countDown(); + pool.shutdown(); + assertTrue(pool.awaitTermination(15, TimeUnit.SECONDS)); + + // All concurrent callers must resolve to the same future user workflow id. + String first = results.peek(); + assertTrue(results.stream().allMatch(first::equals), "All handles must share workflow id"); + + // Wait for the user workflow to complete. + dbos.retrieveWorkflow(first).getResult(); + // Exactly one user workflow executed. + assertEquals(1, serviceImpl.callCount()); + } finally { + pool.shutdownNow(); + } + } + + @Test + public void debouncerOnQueueRunsViaThatQueue() throws Exception { + Queue userQueue = new Queue("debouncer-user-queue"); + dbos.registerQueue(userQueue); + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var debouncer = dbos.debouncer().withQueue(userQueue); + var handle = debouncer.debounce("user-q", Duration.ofMillis(500), () -> svc.process("queued")); + assertEquals("result:queued", handle.getResult()); + + var status = dbos.getWorkflowStatus(handle.workflowId()).orElseThrow(); + assertEquals(userQueue.name(), status.queueName()); + assertEquals(1, serviceImpl.callCount()); + } +} From 421d62dc5908b42e036086a2e2b31f1c4b4b34fb Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 18:14:58 +0300 Subject: [PATCH 02/29] Fix two replay-safety bugs in Debouncer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug 1: userWorkflowId and messageId were generated as UUID.randomUUID() outside any durable step. When debounce() is called from inside a workflow, these values differ on every replay — the returned handle points to a nonexistent workflow and the ack getEvent waits on the wrong key forever. Fix: wrap UUID generation in a runStep when called from a workflow context. Bug 2: Retrieving the existing debouncer's userWorkflowId via status.input()[1] instanceof DebouncerContextOptions always fails on replay. Java records are implicitly final; DBOSJavaSerializer uses NON_FINAL DefaultTyping, so no @class type metadata is written for them. On deserialisation from Object.class the element comes back as LinkedHashMap, not as DebouncerContextOptions, causing an IllegalStateException. Fix: publish userWorkflowId as a named event (DEBOUNCER_CHILD_ID_KEY) at the start of the debouncer-workflow; callers read it via getEvent instead. --- .../java/dev/dbos/transact/Constants.java | 3 ++ .../dev/dbos/transact/workflow/Debouncer.java | 33 ++++++++++++------- .../internal/DebouncerServiceImpl.java | 5 +++ 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/Constants.java b/transact/src/main/java/dev/dbos/transact/Constants.java index 403e62ffb..1822c397c 100644 --- a/transact/src/main/java/dev/dbos/transact/Constants.java +++ b/transact/src/main/java/dev/dbos/transact/Constants.java @@ -18,6 +18,9 @@ public class Constants { public static final String DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"; public static final String DEBOUNCER_TOPIC = "_dbos_debouncer_topic"; + // Event key published by the debouncer-workflow so callers can retrieve the pre-assigned + // user workflow id without relying on Jackson deserialization of workflow inputs. + public static final String DEBOUNCER_CHILD_ID_KEY = "_dbos_debouncer_child_id"; public static final String SYSTEM_JDBC_URL_ENV_VAR = "DBOS_SYSTEM_JDBC_URL"; diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 52f4544b4..46230ff1b 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -155,8 +155,18 @@ private WorkflowHandle debounceInternal( DBOSIntegration.CapturedInvocation invocation = dbos.integration().captureInvocation(wfLambda); - String userWorkflowId = UUID.randomUUID().toString(); - String messageId = UUID.randomUUID().toString(); + // When called from inside a workflow, UUID generation must be wrapped in a durable step so + // the same IDs are produced on replay. UUIDs are joined with "|" (not present in UUID format) + // to avoid two separate step increments. + String ids; + if (DBOS.inWorkflow() && !DBOS.inStep()) { + ids = dbos.runStep(() -> UUID.randomUUID() + "|" + UUID.randomUUID(), "assignDebounceIds"); + } else { + ids = UUID.randomUUID() + "|" + UUID.randomUUID(); + } + String[] idParts = ids.split("\\|", 2); + String userWorkflowId = idParts[0]; + String messageId = idParts[1]; String deduplicationId = invocation.workflowName() + "-" + debounceKey; long periodMs = debouncePeriod.toMillis(); @@ -203,18 +213,17 @@ private WorkflowHandle debounceInternal( continue; } // The existing debouncer absorbed our call. Read the pre-assigned user workflow id - // from its persisted inputs and return a handle to it. - var status = dbos.getWorkflowStatus(existingDebouncerId).orElse(null); - if (status == null || status.input() == null) { - logger.debug("Debouncer {} status unavailable; retrying", existingDebouncerId); + // from the event it published at startup — avoids relying on Jackson deserialising + // record types from Object[] (records are final, so @class type info is not written). + var childIdOpt = + dbos.getEvent( + existingDebouncerId, Constants.DEBOUNCER_CHILD_ID_KEY, ACK_TIMEOUT); + if (childIdOpt.isEmpty()) { + logger.debug( + "Debouncer {} child workflow id not yet available; retrying", existingDebouncerId); continue; } - Object[] dedupInputs = status.input(); - if (dedupInputs.length < 2 || !(dedupInputs[1] instanceof DebouncerContextOptions dco)) { - throw new IllegalStateException( - "Unexpected debouncer workflow inputs for " + existingDebouncerId); - } - return dbos.retrieveWorkflow(dco.userWorkflowId()); + return dbos.retrieveWorkflow(childIdOpt.get()); } } } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index f15ef94db..0720ced50 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -34,6 +34,11 @@ public DebouncerServiceImpl(DBOS dbos) { public String debouncerWorkflow( DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial) { + // Publish the pre-assigned user workflow id as an event so callers waiting on the + // deduplication path can retrieve it via getEvent without relying on Jackson deserializing + // workflow inputs (records are final classes so @class type info is not emitted). + dbos.setEvent(Constants.DEBOUNCER_CHILD_ID_KEY, ctx.userWorkflowId()); + // Record the absolute deadline once as a durable step. On recovery this returns the same // value so the loop's exit condition is replay-stable across crashes. long deadlineEpochMs = From a5354c0195518c3d94897014152f384407fba612 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 18:23:47 +0300 Subject: [PATCH 03/29] Fix type coercion for numeric args in DebouncerServiceImpl Object[] args round-trip through dbos.send/recv as generic JSON types: long 5L serialises to JSON 5 and back to Integer(5) when the target is Object.class, causing IllegalArgumentException when the method expects a primitive long. Adds JsonUtility.coerceArguments() call before startRegisteredWorkflow, mirroring the coercion already applied in executeWorkflowById (line 1344). Adds numericArgsRoundTripCorrectly test that exercises long/double parameters through the full debounce + coalesce path. --- .../internal/DebouncerServiceImpl.java | 15 ++++++++- .../dbos/transact/workflow/DebouncerTest.java | 31 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index 0720ced50..26a6bcc8a 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -3,6 +3,7 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.json.JsonUtility; import dev.dbos.transact.workflow.Workflow; import java.time.Duration; @@ -101,11 +102,23 @@ public String debouncerWorkflow( startOpts = startOpts.withTimeout(Duration.ofMillis(ctx.workflowTimeoutMs())); } + // Coerce args to the method's declared parameter types before invocation. + // Jackson's type-info round-trip through send/recv (Object[]) can produce numeric + // mismatches (e.g. long → Integer) that cause IllegalArgumentException at reflection + // call-site. This mirrors the coercion already applied in executeWorkflowById. + var workflow = optWorkflow.orElseThrow(); + try { + latestArgs = JsonUtility.coerceArguments(latestArgs, workflow.workflowMethod()); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "Debouncer argument coercion failed for workflow " + options.workflowName(), e); + } + logger.debug( "Debouncer starting user workflow {} (id={})", options.workflowName(), ctx.userWorkflowId()); - dbos.integration().startRegisteredWorkflow(optWorkflow.orElseThrow(), latestArgs, startOpts); + dbos.integration().startRegisteredWorkflow(workflow, latestArgs, startOpts); return ctx.userWorkflowId(); } } diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index aaa2069c8..7835a0021 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -197,4 +197,35 @@ public void debouncerOnQueueRunsViaThatQueue() throws Exception { assertEquals(userQueue.name(), status.queueName()); assertEquals(1, serviceImpl.callCount()); } + + // Workflow with numeric parameters to verify type coercion through send/recv round-trip. + public interface NumericService { + long compute(long value, double factor); + } + + public static class NumericServiceImpl implements NumericService { + @Override + @Workflow + public long compute(long value, double factor) { + return (long) (value * factor); + } + } + + @Test + public void numericArgsRoundTripCorrectly() throws Exception { + NumericService svc = dbos.registerProxy(NumericService.class, new NumericServiceImpl()); + dbos.launch(); + + var debouncer = dbos.debouncer(); + // First call + var h1 = debouncer.debounce("num-key", Duration.ofMillis(600), () -> svc.compute(10L, 2.5)); + Thread.sleep(100); + // Second call overrides args — after period the workflow runs with these values + var h2 = debouncer.debounce("num-key", Duration.ofMillis(600), () -> svc.compute(7L, 3.0)); + + assertEquals(h1.workflowId(), h2.workflowId()); + Long result = h2.getResult(); + // 7 * 3.0 = 21 + assertEquals(21L, result); + } } From 7302b638a3b1640a2bff94ec9d79205b081e4d56 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 19:03:52 +0300 Subject: [PATCH 04/29] Replace O(N) debouncer scan with O(1) dedup-id point lookup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lookupExistingDebouncerId previously called listWorkflows and iterated all active debouncer entries in Java to find the one matching the deduplication id. When called from inside a workflow this result was also serialised as a step, making it a potential OOM bomb under load. Add WorkflowDAO.findWorkflowIdByDeduplicationId that issues a direct point-lookup on the UNIQUE (queue_name, deduplication_id) index: SELECT workflow_uuid FROM workflow_status WHERE queue_name = ? AND deduplication_id = ? Expose through SystemDatabase → DBOSExecutor → DBOSIntegration so Debouncer.lookupExistingDebouncerId becomes a single delegation call. --- .../transact/database/SystemDatabase.java | 7 +++++ .../transact/database/dao/WorkflowDAO.java | 27 +++++++++++++++++++ .../dbos/transact/execution/DBOSExecutor.java | 5 ++++ .../transact/internal/DBOSIntegration.java | 17 ++++++++++++ .../dev/dbos/transact/workflow/Debouncer.java | 14 ++-------- 5 files changed, 58 insertions(+), 12 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 dfa0483b7..ad41c2d5b 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -42,6 +42,7 @@ import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -369,6 +370,12 @@ public List listWorkflows(ListWorkflowsInput input) { return dbRetry(() -> WorkflowDAO.listWorkflows(ctx, input)); } + public @Nullable String findWorkflowIdByDeduplicationId( + String queueName, String deduplicationId) { + return dbRetry( + () -> WorkflowDAO.findWorkflowIdByDeduplicationId(ctx, queueName, deduplicationId)); + } + public List getWorkflowAggregates(GetWorkflowAggregatesInput input) { return dbRetry(() -> WorkflowDAO.getWorkflowAggregates(ctx, input)); } diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java index 08408d814..f0bf0378c 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java @@ -51,6 +51,7 @@ import java.util.UUID; import java.util.stream.Stream; +import org.jspecify.annotations.Nullable; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -421,6 +422,32 @@ public static WorkflowStatus getWorkflowStatus( return null; } + /** + * Look up the workflow_uuid of the currently-enqueued or running workflow with a given + * (queue_name, deduplication_id) pair. Uses the UNIQUE index on that pair for O(1) lookup. + * Returns {@code null} if no active workflow with that deduplication id exists. + */ + public static @Nullable String findWorkflowIdByDeduplicationId( + DbContext ctx, String queueName, String deduplicationId) throws SQLException { + var sql = + """ + SELECT workflow_uuid + FROM "%s".workflow_status + WHERE queue_name = ? + AND deduplication_id = ? + LIMIT 1 + """ + .formatted(ctx.schema()); + try (var conn = ctx.getConnection(); + var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, queueName); + stmt.setString(2, deduplicationId); + try (var rs = stmt.executeQuery()) { + return rs.next() ? rs.getString("workflow_uuid") : null; + } + } + } + public static void setWorkflowDelay(DbContext ctx, String workflowId, WorkflowDelay delay) throws SQLException { Objects.requireNonNull(workflowId, "workflowId must not be 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 a787d7344..65d50a6e8 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -337,6 +337,11 @@ SystemDatabase getSystemDatabase() { return systemDatabase; } + public @Nullable String findWorkflowIdByDeduplicationId( + String queueName, String deduplicationId) { + return systemDatabase.findWorkflowIdByDeduplicationId(queueName, deduplicationId); + } + QueueService getQueueService() { return queueService; } diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 040947b07..ea80e7b53 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -186,6 +186,23 @@ public CapturedInvocation captureInvocation( invocation.args()); } + /** + * Find the workflow ID of the currently-active workflow with a given queue and deduplication ID. + * Uses the unique {@code (queue_name, deduplication_id)} index for an O(1) point lookup. Returns + * {@code null} if no active (ENQUEUED, DELAYED, or PENDING) workflow with that deduplication ID + * exists in the given queue. + * + * @param queueName name of the queue to search + * @param deduplicationId deduplication ID to look up + * @return the workflow ID, or {@code null} if not found + * @throws IllegalStateException if DBOS has not been launched + */ + public @Nullable String findWorkflowIdByDeduplicationId( + @NonNull String queueName, @NonNull String deduplicationId) { + return executor("findWorkflowIdByDeduplicationId") + .findWorkflowIdByDeduplicationId(queueName, deduplicationId); + } + /** * Start or enqueue a workflow by its {@link RegisteredWorkflow} registration. Intended for use by * event listeners and other infrastructure that dispatches workflows by registration rather than diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 46230ff1b..ba9daa699 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -229,17 +229,7 @@ private WorkflowHandle debounceInternal( } private @Nullable String lookupExistingDebouncerId(String deduplicationId) { - var input = - new ListWorkflowsInput() - .withQueueName(Constants.DBOS_INTERNAL_QUEUE) - .withQueuesOnly(true) - .withWorkflowName(Constants.DEBOUNCER_WORKFLOW_NAME) - .withLoadInput(false); - for (var s : dbos.listWorkflows(input)) { - if (deduplicationId.equals(s.deduplicationId())) { - return s.workflowId(); - } - } - return null; + return dbos.integration() + .findWorkflowIdByDeduplicationId(Constants.DBOS_INTERNAL_QUEUE, deduplicationId); } } From 659db1aadf3b2864745148bcbeb231620fe83c7d Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 19:29:37 +0300 Subject: [PATCH 05/29] Verify and fix debouncer durability for workflow-context call path Two review findings investigated: Reviewed bug: "SQL without status filter -> livelock" Finding: NOT real. updateWorkflowOutcome clears deduplication_id to NULL on completion (WorkflowDAO line 329). PostgreSQL UNIQUE constraints treat NULL != NULL, so the unique slot is freed and a new enqueue succeeds without conflict. findWorkflowIdByDeduplicationId also returns null for completed debouncers since the WHERE deduplication_id = ? predicate never matches NULL. Added regression test reDebouncAfterWindowCloses that confirms two sequential debounce windows on the same key both execute correctly. Reviewed bug: "lookupExistingDebouncerId not a durable step" Finding: REAL when debounce() is called from inside a workflow. If the parent workflow crashes after DBOSQueueDuplicatedException but before the first step (send) is recorded, recovery would re-execute lookupExistingDebouncerId against the live DB rather than replaying a recorded result. This can produce a different debouncer id and break the determinism of the subsequent send and getEvent steps. Python wraps the equivalent call in call_function_as_step. Fix: when DBOS.inWorkflow() && !DBOS.inStep(), record the lookup result as a durable step "lookupDebouncer" so recovery replays it deterministically. --- .../dev/dbos/transact/workflow/Debouncer.java | 8 +++++- .../dbos/transact/workflow/DebouncerTest.java | 27 +++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index ba9daa699..f2451570c 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -193,7 +193,13 @@ private WorkflowHandle debounceInternal( return dbos.retrieveWorkflow(userWorkflowId); } catch (DBOSQueueDuplicatedException dup) { // A debouncer for this key is already running. Forward the latest args to it. - String existingDebouncerId = lookupExistingDebouncerId(deduplicationId); + // When called from inside a workflow, record the result as a durable step so that + // replay returns the same debouncer id and the subsequent send/getEvent steps stay + // deterministic. Mirrors Python's call_function_as_step("DBOS.get_deduplicated_workflow"). + String existingDebouncerId = + (DBOS.inWorkflow() && !DBOS.inStep()) + ? dbos.runStep(() -> lookupExistingDebouncerId(deduplicationId), "lookupDebouncer") + : lookupExistingDebouncerId(deduplicationId); if (existingDebouncerId == null) { // The existing debouncer finished between the enqueue attempt and now. Retry from // scratch — the next enqueue should succeed. diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index 7835a0021..c2b39b6e1 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -228,4 +228,31 @@ public void numericArgsRoundTripCorrectly() throws Exception { // 7 * 3.0 = 21 assertEquals(21L, result); } + + // Verify that a second debounce call after the first window closes starts a fresh window. + // Regression test for: deduplication_id is cleared to NULL on completion, so the UNIQUE + // constraint no longer blocks a new enqueue with the same key. + @Test + public void reDebouncAfterWindowCloses() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var debouncer = dbos.debouncer(); + + // First window + var h1 = debouncer.debounce("rekey", Duration.ofMillis(400), () -> svc.process("first")); + assertEquals("result:first", h1.getResult()); + assertEquals(1, serviceImpl.callCount()); + + // Wait long enough to ensure the first debouncer workflow has completed. + Thread.sleep(300); + + // Second window — must NOT livelock; must start a fresh debouncer. + var h2 = debouncer.debounce("rekey", Duration.ofMillis(400), () -> svc.process("second")); + assertEquals("result:second", h2.getResult()); + assertEquals(2, serviceImpl.callCount()); + + // Each window produces an independent user workflow. + assertNotEquals(h1.workflowId(), h2.workflowId()); + } } From c74ced0670b1eb2b1547f11b5f60d55b41d08618 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 19:53:00 +0300 Subject: [PATCH 06/29] Align Debouncer with Python semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Propagate caller workflow context (priority, appVersion, deduplicationId, timeout) to the user workflow via DebouncerContextOptions. Add these fields to DBOSContext, populate from ExecutionOptions. - Change debouncerWorkflow return type String → void: return value was unused, Python returns None. --- .../dbos/transact/context/DBOSContext.java | 53 ++++++++++++++++++- .../dbos/transact/execution/DBOSExecutor.java | 5 +- .../dev/dbos/transact/workflow/Debouncer.java | 22 +++++++- .../workflow/internal/DebouncerService.java | 5 +- .../internal/DebouncerServiceImpl.java | 3 +- 5 files changed, 79 insertions(+), 9 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java b/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java index 499e69b16..95511c51a 100644 --- a/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java +++ b/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java @@ -23,6 +23,9 @@ public class DBOSContext { private final Duration timeout; private final Instant deadline; private SerializationStrategy serialization; + private final Integer priority; + private final String appVersion; + private final String deduplicationId; // private StepStatus stepStatus; @@ -33,10 +36,13 @@ public DBOSContext() { timeout = null; deadline = null; serialization = SerializationStrategy.DEFAULT; + priority = null; + appVersion = null; + deduplicationId = null; } public DBOSContext(String workflowId, WorkflowInfo parent, Duration timeout, Instant deadline) { - this(workflowId, parent, timeout, deadline, null); + this(workflowId, parent, timeout, deadline, null, null, null, null); } public DBOSContext( @@ -45,12 +51,27 @@ public DBOSContext( Duration timeout, Instant deadline, SerializationStrategy serialization) { + this(workflowId, parent, timeout, deadline, serialization, null, null, null); + } + + public DBOSContext( + String workflowId, + WorkflowInfo parent, + Duration timeout, + Instant deadline, + SerializationStrategy serialization, + Integer priority, + String appVersion, + String deduplicationId) { this.workflowId = workflowId; this.functionId = 0; this.parent = parent; this.timeout = timeout; this.deadline = deadline; this.serialization = serialization; + this.priority = priority; + this.appVersion = appVersion; + this.deduplicationId = deduplicationId; } public DBOSContext( @@ -68,6 +89,9 @@ public DBOSContext( this.timeout = other.timeout; this.deadline = other.deadline; this.serialization = other.serialization; + this.priority = other.priority; + this.appVersion = other.appVersion; + this.deduplicationId = other.deduplicationId; } public boolean isInWorkflow() { @@ -168,4 +192,31 @@ public static SerializationStrategy serializationStrategy() { var ctx = DBOSContextHolder.get(); return ctx != null ? ctx.getSerialization() : null; } + + public Integer getPriority() { + return priority; + } + + public String getAppVersion() { + return appVersion; + } + + public String getDeduplicationId() { + return deduplicationId; + } + + public static Integer currentPriority() { + var ctx = DBOSContextHolder.get(); + return ctx != null ? ctx.priority : null; + } + + public static String currentAppVersion() { + var ctx = DBOSContextHolder.get(); + return ctx != null ? ctx.appVersion : null; + } + + public static String currentDeduplicationId() { + var ctx = DBOSContextHolder.get(); + return ctx != null ? ctx.deduplicationId : 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 65d50a6e8..c89979ef5 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -1569,7 +1569,10 @@ private WorkflowHandle executeWorkflow( finalOptions.deadline(), SerializationUtil.PORTABLE.equals(initResult.serialization()) ? SerializationStrategy.PORTABLE - : SerializationStrategy.DEFAULT)); + : SerializationStrategy.DEFAULT, + finalOptions.priority(), + finalOptions.appVersion(), + finalOptions.deduplicationId())); if (Thread.currentThread().isInterrupted()) { logger.debug("executeWorkflow task interrupted before workflow.invoke"); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index f2451570c..5041181e1 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -3,6 +3,8 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.context.DBOSContext; +import dev.dbos.transact.context.DBOSContextHolder; import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; import dev.dbos.transact.execution.ThrowingRunnable; import dev.dbos.transact.execution.ThrowingSupplier; @@ -177,8 +179,22 @@ private WorkflowHandle debounceInternal( invocation.instanceName(), queueName, debounceTimeout == null ? null : debounceTimeout.toMillis()); + // Propagate the calling workflow's context (priority, timeout, appVersion, deduplicationId) + // to the user workflow — mirrors Python's ContextOptions snapshot. + Long timeoutMs = null; + if (DBOS.inWorkflow()) { + var wfCtx = DBOSContextHolder.get(); + if (wfCtx != null && wfCtx.getTimeout() != null) { + timeoutMs = wfCtx.getTimeout().toMillis(); + } + } DebouncerContextOptions ctx = - new DebouncerContextOptions(userWorkflowId, null, null, null, null); + new DebouncerContextOptions( + userWorkflowId, + DBOSContext.currentDeduplicationId(), + DBOSContext.currentPriority(), + DBOSContext.currentAppVersion(), + timeoutMs); DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), periodMs); while (true) { @@ -188,7 +204,9 @@ private WorkflowHandle debounceInternal( .withQueue(Constants.DBOS_INTERNAL_QUEUE) .withDeduplicationId(deduplicationId); dbos.startWorkflow( - () -> debouncerProxy.debouncerWorkflow(options, ctx, initial), startOpts); + (dev.dbos.transact.execution.ThrowingRunnable) + () -> debouncerProxy.debouncerWorkflow(options, ctx, initial), + startOpts); // Successfully enqueued a fresh debouncer for this key. return dbos.retrieveWorkflow(userWorkflowId); } catch (DBOSQueueDuplicatedException dup) { diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java index 0632a88d7..6335f2ded 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java @@ -9,13 +9,12 @@ public interface DebouncerService { /** - * The debouncer service workflow. + * The debouncer service workflow. Runs the recv-loop, then starts the user workflow. * * @param options identifies the user workflow to start and the absolute timeout * @param ctx caller context forwarded to the user workflow * @param initial initial debounce message from the first caller - * @return the user workflow id (the same value carried in {@code ctx.userWorkflowId()}) */ - String debouncerWorkflow( + void debouncerWorkflow( DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial); } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index 26a6bcc8a..cf7447922 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -32,7 +32,7 @@ public DebouncerServiceImpl(DBOS dbos) { @Override @Workflow(name = Constants.DEBOUNCER_WORKFLOW_NAME) - public String debouncerWorkflow( + public void debouncerWorkflow( DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial) { // Publish the pre-assigned user workflow id as an event so callers waiting on the @@ -119,6 +119,5 @@ public String debouncerWorkflow( options.workflowName(), ctx.userWorkflowId()); dbos.integration().startRegisteredWorkflow(workflow, latestArgs, startOpts); - return ctx.userWorkflowId(); } } From 2d39757adf33cce0ca8bda10d8a741b5af599f21 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 20:23:41 +0300 Subject: [PATCH 07/29] Fix send accumulation and dead code in dedup path - Guard send with messageSent flag: only one message per debounce() call - Replace unreachable childIdOpt.isEmpty() continue with IllegalStateException --- .../dev/dbos/transact/workflow/Debouncer.java | 35 ++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 5041181e1..d1ae67e5c 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -197,6 +197,9 @@ private WorkflowHandle debounceInternal( timeoutMs); DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), periodMs); + // Tracks whether we already sent a message to the running debouncer. The message persists in + // the notifications table until processed, so we must not re-send on each ack-timeout retry. + boolean messageSent = false; while (true) { try { var startOpts = @@ -226,7 +229,14 @@ private WorkflowHandle debounceInternal( continue; } DebouncerMessage msg = new DebouncerMessage(messageId, invocation.args(), periodMs); - dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC); + // Send only once per call: messageId is fixed, so re-sending the same id on each + // retry would accumulate identical messages in the debouncer's inbox (each consuming + // a durable step when inside a workflow). The message persists in the notifications + // table until the debouncer processes it, so a single send is sufficient. + if (!messageSent) { + dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC); + messageSent = true; + } // Wait for the debouncer to acknowledge receipt. If the debouncer exited before // processing this message, no ack arrives — start over. @@ -236,18 +246,19 @@ private WorkflowHandle debounceInternal( "Debouncer {} did not ack message {}; retrying", existingDebouncerId, messageId); continue; } - // The existing debouncer absorbed our call. Read the pre-assigned user workflow id - // from the event it published at startup — avoids relying on Jackson deserialising - // record types from Object[] (records are final, so @class type info is not written). - var childIdOpt = + // CHILD_ID_KEY is set as the debouncer workflow's first action, before the recv-loop. + // If the ack arrived, the debouncer has already published this event — it cannot be empty. + var childId = dbos.getEvent( - existingDebouncerId, Constants.DEBOUNCER_CHILD_ID_KEY, ACK_TIMEOUT); - if (childIdOpt.isEmpty()) { - logger.debug( - "Debouncer {} child workflow id not yet available; retrying", existingDebouncerId); - continue; - } - return dbos.retrieveWorkflow(childIdOpt.get()); + existingDebouncerId, Constants.DEBOUNCER_CHILD_ID_KEY, ACK_TIMEOUT) + .orElseThrow( + () -> + new IllegalStateException( + "Debouncer " + + existingDebouncerId + + " acked but did not publish " + + Constants.DEBOUNCER_CHILD_ID_KEY)); + return dbos.retrieveWorkflow(childId); } } } From 5b8faec666144468eba4c6b2cdcdfd20b878c2b4 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Tue, 19 May 2026 20:54:22 +0300 Subject: [PATCH 08/29] Add debouncer tests; fix priority context propagation for dequeued workflows - Add debounceVoid, absoluteTimeoutUsesLatestArgs, priorityPropagatedFromCallerContext tests - executeWorkflowById now restores priority/appVersion from workflow_status so DBOSContext.currentPriority() is non-null inside dequeued workflows - Skip queue-option validation for dequeued/recovered workflows - DebouncerServiceImpl: skip priority/deduplicationId when no user queue --- .../dbos/transact/execution/DBOSExecutor.java | 35 +++--- .../transact/execution/ExecutionOptions.java | 32 +++++ .../internal/DebouncerServiceImpl.java | 7 +- .../dbos/transact/workflow/DebouncerTest.java | 111 ++++++++++++++++++ 4 files changed, 168 insertions(+), 17 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 c89979ef5..67f519345 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -1353,9 +1353,13 @@ public WorkflowHandle executeWorkflowById( throw e; } + // Restore queue-related metadata so the workflow context carries them during execution. + // This is needed for context propagation (e.g. priority forwarded to child workflows). var options = new ExecutionOptions(workflowId, status.timeout(), status.deadline()) - .withSerialization(status.serialization()); + .withSerialization(status.serialization()) + .withPriority(status.priority()) + .withAppVersion(status.appVersion()); if (isRecoveryRequest) options = options.asRecoveryRequest(); if (isDequeuedRequest) options = options.asDequeuedRequest(); return executeWorkflow(workflow, inputs, options, null); @@ -1485,21 +1489,22 @@ private WorkflowHandle executeWorkflow( return new WorkflowHandleDBPoll<>(this, workflowId); } + // For dequeued or recovered workflows, queue-related options are restored from + // workflow_status for context propagation. Skip the validation that would reject them. var badOptionList = new ArrayList(); - if (options.deduplicationId() != null) { - badOptionList.add("deduplicationId"); - } - - if (options.priority() != null) { - badOptionList.add("priority"); - } - - if (options.queuePartitionKey() != null) { - badOptionList.add("queuePartitionKey"); - } - - if (options.delay() != null) { - badOptionList.add("delay"); + if (!options.isDequeuedRequest() && !options.isRecoveryRequest()) { + if (options.deduplicationId() != null) { + badOptionList.add("deduplicationId"); + } + if (options.priority() != null) { + badOptionList.add("priority"); + } + if (options.queuePartitionKey() != null) { + badOptionList.add("queuePartitionKey"); + } + if (options.delay() != null) { + badOptionList.add("delay"); + } } if (!badOptionList.isEmpty()) { diff --git a/transact/src/main/java/dev/dbos/transact/execution/ExecutionOptions.java b/transact/src/main/java/dev/dbos/transact/execution/ExecutionOptions.java index c4e953bb4..0340c96a6 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/ExecutionOptions.java +++ b/transact/src/main/java/dev/dbos/transact/execution/ExecutionOptions.java @@ -126,6 +126,38 @@ public ExecutionOptions withSerialization(String serialization) { serialization); } + public ExecutionOptions withPriority(Integer priority) { + return new ExecutionOptions( + this.workflowId, + this.timeout, + this.deadline, + this.queueName, + this.deduplicationId, + priority, + this.queuePartitionKey, + this.delay, + this.appVersion, + this.isRecoveryRequest, + this.isDequeuedRequest, + this.serialization); + } + + public ExecutionOptions withAppVersion(String appVersion) { + return new ExecutionOptions( + this.workflowId, + this.timeout, + this.deadline, + this.queueName, + this.deduplicationId, + this.priority, + this.queuePartitionKey, + this.delay, + appVersion, + this.isRecoveryRequest, + this.isDequeuedRequest, + this.serialization); + } + public Duration timeoutDuration() { if (timeout instanceof Timeout.Explicit e) { return e.value(); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index cf7447922..c449f082c 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -91,12 +91,15 @@ public void debouncerWorkflow( + (options.instanceName() == null ? "" : " / " + options.instanceName())); } + // priority and deduplicationId are only valid for queued workflows; the executor + // throws IllegalArgumentException if they are set without a queue name. + boolean hasQueue = options.queueName() != null; var startOpts = new StartWorkflowOptions() .withWorkflowId(ctx.userWorkflowId()) .withQueue(options.queueName()) - .withDeduplicationId(ctx.deduplicationId()) - .withPriority(ctx.priority()) + .withDeduplicationId(hasQueue ? ctx.deduplicationId() : null) + .withPriority(hasQueue ? ctx.priority() : null) .withAppVersion(ctx.appVersion()); if (ctx.workflowTimeoutMs() != null) { startOpts = startOpts.withTimeout(Duration.ofMillis(ctx.workflowTimeoutMs())); diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index c2b39b6e1..1170f0983 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -2,9 +2,11 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; import static org.junit.jupiter.api.Assertions.assertTrue; import dev.dbos.transact.DBOS; +import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.config.DBOSConfig; import dev.dbos.transact.utils.PgContainer; @@ -229,6 +231,115 @@ public void numericArgsRoundTripCorrectly() throws Exception { assertEquals(21L, result); } + // Verify that debounceVoid works for workflows with no return value. + public interface VoidService { + void doWork(String marker); + } + + public static class VoidServiceImpl implements VoidService { + final AtomicInteger callCount = new AtomicInteger(); + final ConcurrentLinkedQueue markers = new ConcurrentLinkedQueue<>(); + + @Override + @Workflow + public void doWork(String marker) { + callCount.incrementAndGet(); + markers.add(marker); + } + } + + @Test + public void debounceVoidCoalescesCorrectly() throws Exception { + var impl = new VoidServiceImpl(); + VoidService svc = dbos.registerProxy(VoidService.class, impl); + dbos.launch(); + + var debouncer = dbos.debouncer(); + var h1 = debouncer.debounceVoid("void-key", Duration.ofMillis(500), () -> svc.doWork("a")); + Thread.sleep(100); + var h2 = debouncer.debounceVoid("void-key", Duration.ofMillis(500), () -> svc.doWork("b")); + + h2.getResult(); + assertEquals(h1.workflowId(), h2.workflowId()); + assertEquals(1, impl.callCount.get()); + assertEquals(List.of("b"), List.copyOf(impl.markers)); + } + + // Verify that absoluteTimeout fires with the LATEST args, not the first. + @Test + public void absoluteTimeoutUsesLatestArgs() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + // Long period (5s) so normal expiry cannot fire; only the 1.5s absolute timeout can. + var debouncer = dbos.debouncer().withDebounceTimeout(Duration.ofMillis(1500)); + + var h = debouncer.debounce("abs-key", Duration.ofSeconds(5), () -> svc.process("first")); + Thread.sleep(500); + debouncer.debounce("abs-key", Duration.ofSeconds(5), () -> svc.process("last")); + + String result = h.getResult(); + assertEquals("result:last", result); + assertEquals(1, serviceImpl.callCount()); + assertEquals(List.of("last"), serviceImpl.callArgs()); + } + + // Wrapper workflow that calls debounce() internally so caller context (priority) + // is available via DBOSContext when DebouncerContextOptions is built. + public interface OrchestratorService { + String debounceWithPriority(String arg); + } + + public static class OrchestratorServiceImpl implements OrchestratorService { + private final DBOS dbos; + private final DebouncedService svc; + private final Queue userQueue; + + public OrchestratorServiceImpl(DBOS dbos, DebouncedService svc, Queue userQueue) { + this.dbos = dbos; + this.svc = svc; + this.userQueue = userQueue; + } + + @Override + @Workflow + public String debounceWithPriority(String arg) { + // withQueue ensures priority is forwarded (priority is only valid for queued workflows). + return dbos.debouncer() + .withQueue(userQueue) + .debounce("prio-inner", Duration.ofMillis(400), () -> svc.process(arg)) + .getResult(); + } + } + + // Verify that priority from caller workflow context is propagated to the user workflow. + @Test + public void priorityPropagatedFromCallerContext() throws Exception { + Queue q = new Queue("prio-queue").withPriorityEnabled(true); + dbos.registerQueue(q); + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + var orch = + dbos.registerProxy(OrchestratorService.class, new OrchestratorServiceImpl(dbos, svc, q)); + dbos.launch(); + + // Start the orchestrator with priority=42. + var opts = new StartWorkflowOptions().withQueue(q).withPriority(42); + var h = dbos.startWorkflow(() -> orch.debounceWithPriority("prio-val"), opts); + assertEquals("result:prio-val", h.getResult()); + + // The user workflow started by the debouncer should have inherited priority=42. + // It runs on queue q, so priority is stored in workflow_status. + var userWfStatus = + dbos + .listWorkflows( + new ListWorkflowsInput().withQueueName(q.name()).withWorkflowName("process")) + .stream() + .findFirst() + .orElse(null); + assertNotNull(userWfStatus, "user workflow 'process' not found on queue " + q.name()); + assertEquals(Integer.valueOf(42), userWfStatus.priority()); + } + // Verify that a second debounce call after the first window closes starts a fresh window. // Regression test for: deduplication_id is cleared to NULL on completion, so the UNIQUE // constraint no longer blocks a new enqueue with the same key. From e7b85190c852181d176b5a4ff57a518f7a2a013a Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 14:14:05 +0300 Subject: [PATCH 09/29] Address minor PR review comments for Debouncer - Use Duration/Instant instead of long/Long in DebouncerOptions, DebouncerMessage, and DebouncerContextOptions - Register debouncer workflow via registerWorkflow/startRegisteredWorkflow instead of a proxy; store RegisteredWorkflow instead of DebouncerService - Add null-but-not-empty guard on Debouncer.withQueue(String) - Use send idempotency key instead of messageSent tracking flag - Remove spurious null check on DBOSContextHolder.get() inside workflow - Replace "|"-join UUID hack with a private DebounceIds record - Make instanceName @Nullable in getRegisteredWorkflow (DBOSExecutor, DBOSIntegration); drop manual null coercion at call sites --- .../src/main/java/dev/dbos/transact/DBOS.java | 23 +++-- .../dbos/transact/execution/DBOSExecutor.java | 2 +- .../transact/internal/DBOSIntegration.java | 2 +- .../dev/dbos/transact/workflow/Debouncer.java | 84 +++++++++---------- .../internal/DebouncerContextOptions.java | 4 +- .../workflow/internal/DebouncerMessage.java | 4 +- .../workflow/internal/DebouncerOptions.java | 4 +- .../internal/DebouncerServiceImpl.java | 46 +++++----- 8 files changed, 92 insertions(+), 77 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index a1cb0eb07..776f75a06 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -22,11 +22,11 @@ import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.VersionInfo; import dev.dbos.transact.workflow.Workflow; +import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.workflow.WorkflowDelay; import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowSchedule; import dev.dbos.transact.workflow.WorkflowStatus; -import dev.dbos.transact.workflow.internal.DebouncerService; import dev.dbos.transact.workflow.internal.DebouncerServiceImpl; import java.io.IOException; @@ -65,7 +65,7 @@ public class DBOS implements AutoCloseable { private final DBOSConfig config; private final AtomicReference dbosExecutor = new AtomicReference<>(); private final DBOSIntegration integration; - private final DebouncerService debouncerProxy; + private final RegisteredWorkflow debouncerWorkflow; private AlertHandler alertHandler; @@ -88,9 +88,20 @@ public DBOS(@NonNull DBOSConfig config) { this.integration = new DBOSIntegration( this.config, this.workflowRegistry, dbosExecutor::get, this::registerLifecycleListener); - // Register the built-in debouncer service workflow so callers can use Debouncer without - // having to declare and wire the service themselves. - this.debouncerProxy = registerProxy(DebouncerService.class, new DebouncerServiceImpl(this)); + // Register the built-in debouncer service workflow directly (without a proxy) so callers can + // use Debouncer without having to declare and wire the service themselves. + var debouncerImpl = new DebouncerServiceImpl(this); + workflowRegistry.registerInstance(null, debouncerImpl); + RegisteredWorkflow rw = null; + for (var m : DebouncerServiceImpl.class.getDeclaredMethods()) { + if (m.isAnnotationPresent(dev.dbos.transact.workflow.Workflow.class)) { + m.setAccessible(true); + rw = integration.registerWorkflow(m.getAnnotation(dev.dbos.transact.workflow.Workflow.class), debouncerImpl, m, null); + break; + } + } + this.debouncerWorkflow = + Objects.requireNonNull(rw, "DebouncerServiceImpl must have a @Workflow method"); } /** @@ -408,7 +419,7 @@ public void sleep(@NonNull Duration duration) { * @return a fresh debouncer bound to this DBOS instance */ public @NonNull Debouncer debouncer() { - return new Debouncer<>(this, debouncerProxy); + return new Debouncer<>(this, debouncerWorkflow); } /** 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 67f519345..97760cc28 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -381,7 +381,7 @@ public Collection getRegisteredWorkflowInstances() { } public Optional getRegisteredWorkflow( - String workflowName, String className, String instanceName) { + String workflowName, String className, @Nullable String instanceName) { var fqName = RegisteredWorkflow.fullyQualifiedName(workflowName, className, instanceName); return Optional.ofNullable(this.workflowMap.get(fqName)); } diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index ea80e7b53..5dd6a63d6 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -307,7 +307,7 @@ public Optional getRegisteredWorkflow( * @return an Optional containing the RegisteredWorkflow if found, otherwise empty */ public Optional getRegisteredWorkflow( - @NonNull String workflowName, @NonNull String className, @NonNull String instanceName) { + @NonNull String workflowName, @NonNull String className, @Nullable String instanceName) { var executor = executorSupplier.get(); if (executor != null) { return executor.getRegisteredWorkflow(workflowName, className, instanceName); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index d1ae67e5c..d7a8326a7 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -6,13 +6,13 @@ import dev.dbos.transact.context.DBOSContext; import dev.dbos.transact.context.DBOSContextHolder; import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; +import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.ThrowingRunnable; import dev.dbos.transact.execution.ThrowingSupplier; import dev.dbos.transact.internal.DBOSIntegration; import dev.dbos.transact.workflow.internal.DebouncerContextOptions; import dev.dbos.transact.workflow.internal.DebouncerMessage; import dev.dbos.transact.workflow.internal.DebouncerOptions; -import dev.dbos.transact.workflow.internal.DebouncerService; import java.time.Duration; import java.util.Objects; @@ -64,22 +64,26 @@ public final class Debouncer { */ private static final Duration ACK_TIMEOUT = Duration.ofSeconds(1); + // Fix 9: record for carrying the two pre-assigned IDs out of a durable step + private record DebounceIds(String userWorkflowId, String messageId) {} + private final DBOS dbos; - private final DebouncerService debouncerProxy; + private final RegisteredWorkflow debouncerWorkflow; private final @Nullable String queueName; private final @Nullable Duration debounceTimeout; - public Debouncer(@NonNull DBOS dbos, @NonNull DebouncerService debouncerProxy) { - this(dbos, debouncerProxy, null, null); + public Debouncer(@NonNull DBOS dbos, @NonNull RegisteredWorkflow debouncerWorkflow) { + this(dbos, debouncerWorkflow, null, null); } private Debouncer( DBOS dbos, - DebouncerService debouncerProxy, + RegisteredWorkflow debouncerWorkflow, @Nullable String queueName, @Nullable Duration debounceTimeout) { this.dbos = Objects.requireNonNull(dbos, "dbos must not be null"); - this.debouncerProxy = Objects.requireNonNull(debouncerProxy, "debouncerProxy must not be null"); + this.debouncerWorkflow = + Objects.requireNonNull(debouncerWorkflow, "debouncerWorkflow must not be null"); this.queueName = queueName; this.debounceTimeout = debounceTimeout; } @@ -89,7 +93,10 @@ private Debouncer( * {@code null} starts the user workflow directly (not enqueued). */ public @NonNull Debouncer withQueue(@Nullable String queueName) { - return new Debouncer<>(dbos, debouncerProxy, queueName, debounceTimeout); + if (queueName != null && queueName.isEmpty()) { + throw new IllegalArgumentException("queueName must not be empty"); + } + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout); } /** See {@link #withQueue(String)}. */ @@ -103,7 +110,7 @@ private Debouncer( * arriving. */ public @NonNull Debouncer withDebounceTimeout(@Nullable Duration debounceTimeout) { - return new Debouncer<>(dbos, debouncerProxy, queueName, debounceTimeout); + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout); } /** @@ -157,59 +164,52 @@ private WorkflowHandle debounceInternal( DBOSIntegration.CapturedInvocation invocation = dbos.integration().captureInvocation(wfLambda); - // When called from inside a workflow, UUID generation must be wrapped in a durable step so - // the same IDs are produced on replay. UUIDs are joined with "|" (not present in UUID format) - // to avoid two separate step increments. - String ids; + // Fix 9: use a record to carry both IDs out of a single durable step, eliminating + // the "|"-join/split hack. Inside a workflow the step makes replay deterministic. + DebounceIds ids; if (DBOS.inWorkflow() && !DBOS.inStep()) { - ids = dbos.runStep(() -> UUID.randomUUID() + "|" + UUID.randomUUID(), "assignDebounceIds"); + ids = + dbos.runStep( + () -> new DebounceIds(UUID.randomUUID().toString(), UUID.randomUUID().toString()), + "assignDebounceIds"); } else { - ids = UUID.randomUUID() + "|" + UUID.randomUUID(); + ids = new DebounceIds(UUID.randomUUID().toString(), UUID.randomUUID().toString()); } - String[] idParts = ids.split("\\|", 2); - String userWorkflowId = idParts[0]; - String messageId = idParts[1]; + String userWorkflowId = ids.userWorkflowId(); + String messageId = ids.messageId(); String deduplicationId = invocation.workflowName() + "-" + debounceKey; - long periodMs = debouncePeriod.toMillis(); + // Fix 4: pass Duration directly instead of converting to millis DebouncerOptions options = new DebouncerOptions( invocation.workflowName(), invocation.className(), invocation.instanceName(), queueName, - debounceTimeout == null ? null : debounceTimeout.toMillis()); + debounceTimeout); + // Fix 8: DBOSContextHolder.get() is guaranteed non-null inside a workflow context // Propagate the calling workflow's context (priority, timeout, appVersion, deduplicationId) // to the user workflow — mirrors Python's ContextOptions snapshot. - Long timeoutMs = null; - if (DBOS.inWorkflow()) { - var wfCtx = DBOSContextHolder.get(); - if (wfCtx != null && wfCtx.getTimeout() != null) { - timeoutMs = wfCtx.getTimeout().toMillis(); - } - } + Duration workflowTimeout = DBOS.inWorkflow() ? DBOSContextHolder.get().getTimeout() : null; DebouncerContextOptions ctx = new DebouncerContextOptions( userWorkflowId, DBOSContext.currentDeduplicationId(), DBOSContext.currentPriority(), DBOSContext.currentAppVersion(), - timeoutMs); - DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), periodMs); + workflowTimeout); + DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), debouncePeriod); - // Tracks whether we already sent a message to the running debouncer. The message persists in - // the notifications table until processed, so we must not re-send on each ack-timeout retry. - boolean messageSent = false; while (true) { try { var startOpts = new StartWorkflowOptions() .withQueue(Constants.DBOS_INTERNAL_QUEUE) .withDeduplicationId(deduplicationId); - dbos.startWorkflow( - (dev.dbos.transact.execution.ThrowingRunnable) - () -> debouncerProxy.debouncerWorkflow(options, ctx, initial), - startOpts); + // Fix 5: use startRegisteredWorkflow instead of startWorkflow with proxy lambda + dbos.integration() + .startRegisteredWorkflow( + debouncerWorkflow, new Object[] {options, ctx, initial}, startOpts); // Successfully enqueued a fresh debouncer for this key. return dbos.retrieveWorkflow(userWorkflowId); } catch (DBOSQueueDuplicatedException dup) { @@ -228,15 +228,11 @@ private WorkflowHandle debounceInternal( "Debouncer for dedupId {} not found after conflict; retrying", deduplicationId); continue; } - DebouncerMessage msg = new DebouncerMessage(messageId, invocation.args(), periodMs); - // Send only once per call: messageId is fixed, so re-sending the same id on each - // retry would accumulate identical messages in the debouncer's inbox (each consuming - // a durable step when inside a workflow). The message persists in the notifications - // table until the debouncer processes it, so a single send is sufficient. - if (!messageSent) { - dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC); - messageSent = true; - } + DebouncerMessage msg = + new DebouncerMessage(messageId, invocation.args(), debouncePeriod); + // Fix 7: use messageId as idempotency key — the send overload guarantees exactly-once + // delivery without needing a separate messageSent tracking flag. + dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC, messageId); // Wait for the debouncer to acknowledge receipt. If the debouncer exited before // processing this message, no ack arrives — start over. diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java index 708de9722..3c1f0975f 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java @@ -1,5 +1,7 @@ package dev.dbos.transact.workflow.internal; +import java.time.Duration; + import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -15,4 +17,4 @@ public record DebouncerContextOptions( @Nullable String deduplicationId, @Nullable Integer priority, @Nullable String appVersion, - @Nullable Long workflowTimeoutMs) {} + @Nullable Duration workflowTimeout) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java index 84abc9a3a..5c8f836e6 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerMessage.java @@ -1,5 +1,7 @@ package dev.dbos.transact.workflow.internal; +import java.time.Duration; + import org.jspecify.annotations.NonNull; /** @@ -10,4 +12,4 @@ *

Not part of the public API — the debouncer infrastructure consumes this directly. */ public record DebouncerMessage( - @NonNull String messageId, @NonNull Object[] args, long debouncePeriodMs) {} + @NonNull String messageId, @NonNull Object[] args, @NonNull Duration debouncePeriod) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java index 68805fbd0..11b4512f4 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java @@ -1,5 +1,7 @@ package dev.dbos.transact.workflow.internal; +import java.time.Duration; + import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -14,4 +16,4 @@ public record DebouncerOptions( @NonNull String className, @Nullable String instanceName, @Nullable String queueName, - @Nullable Long debounceTimeoutMs) {} + @Nullable Duration debounceTimeout) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index c449f082c..f04e20d61 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -45,15 +45,15 @@ public void debouncerWorkflow( long deadlineEpochMs = dbos.runStep( () -> { - if (options.debounceTimeoutMs() == null) { + if (options.debounceTimeout() == null) { return Long.MAX_VALUE; } - return Instant.now().toEpochMilli() + options.debounceTimeoutMs(); + return Instant.now().plus(options.debounceTimeout()).toEpochMilli(); }, "DBOS.debouncerComputeDeadline"); Object[] latestArgs = initial.args(); - long debouncePeriodMs = initial.debouncePeriodMs(); + Duration debouncePeriod = initial.debouncePeriod(); while (true) { long now = dbos.runStep(() -> Instant.now().toEpochMilli(), "DBOS.debouncerNow"); @@ -61,35 +61,38 @@ public void debouncerWorkflow( break; } long timeUntilDeadlineMs = Math.max(deadlineEpochMs - now, 0); - long waitMs = Math.min(debouncePeriodMs, timeUntilDeadlineMs); + Duration waitDuration = + debouncePeriod.toMillis() <= timeUntilDeadlineMs + ? debouncePeriod + : Duration.ofMillis(timeUntilDeadlineMs); - Optional msg = - dbos.recv(Constants.DEBOUNCER_TOPIC, Duration.ofMillis(waitMs)); + Optional msg = dbos.recv(Constants.DEBOUNCER_TOPIC, waitDuration); if (msg.isEmpty()) { // Period elapsed with no new message — fire. break; } DebouncerMessage next = msg.get(); latestArgs = next.args(); - debouncePeriodMs = next.debouncePeriodMs(); + debouncePeriod = next.debouncePeriod(); // Acknowledge receipt so the sender knows the message was consumed by this loop iteration. dbos.setEvent(next.messageId(), next.messageId()); } - var optWorkflow = + // Fix 11: combine isEmpty check with orElseThrow + var workflow = dbos.integration() .getRegisteredWorkflow( - options.workflowName(), - options.className(), - options.instanceName() == null ? "" : options.instanceName()); - if (optWorkflow.isEmpty()) { - throw new IllegalStateException( - "Debouncer cannot find registered user workflow: " - + options.workflowName() - + " / " - + options.className() - + (options.instanceName() == null ? "" : " / " + options.instanceName())); - } + options.workflowName(), options.className(), options.instanceName()) + .orElseThrow( + () -> + new IllegalStateException( + "Debouncer cannot find registered user workflow: " + + options.workflowName() + + " / " + + options.className() + + (options.instanceName() == null + ? "" + : " / " + options.instanceName()))); // priority and deduplicationId are only valid for queued workflows; the executor // throws IllegalArgumentException if they are set without a queue name. @@ -101,15 +104,14 @@ public void debouncerWorkflow( .withDeduplicationId(hasQueue ? ctx.deduplicationId() : null) .withPriority(hasQueue ? ctx.priority() : null) .withAppVersion(ctx.appVersion()); - if (ctx.workflowTimeoutMs() != null) { - startOpts = startOpts.withTimeout(Duration.ofMillis(ctx.workflowTimeoutMs())); + if (ctx.workflowTimeout() != null) { + startOpts = startOpts.withTimeout(ctx.workflowTimeout()); } // Coerce args to the method's declared parameter types before invocation. // Jackson's type-info round-trip through send/recv (Object[]) can produce numeric // mismatches (e.g. long → Integer) that cause IllegalArgumentException at reflection // call-site. This mirrors the coercion already applied in executeWorkflowById. - var workflow = optWorkflow.orElseThrow(); try { latestArgs = JsonUtility.coerceArguments(latestArgs, workflow.workflowMethod()); } catch (IllegalArgumentException e) { From 46af720814c77f0696031f819b9dd3f5b4852858 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 14:33:09 +0300 Subject: [PATCH 10/29] Duration/Instant classes for time related calculations --- .../internal/DebouncerServiceImpl.java | 20 ++++++++----------- 1 file changed, 8 insertions(+), 12 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index f04e20d61..b9fbb7d10 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -44,27 +44,23 @@ public void debouncerWorkflow( // value so the loop's exit condition is replay-stable across crashes. long deadlineEpochMs = dbos.runStep( - () -> { - if (options.debounceTimeout() == null) { - return Long.MAX_VALUE; - } - return Instant.now().plus(options.debounceTimeout()).toEpochMilli(); - }, + () -> + options.debounceTimeout() == null + ? Long.MAX_VALUE + : Instant.now().plus(options.debounceTimeout()).toEpochMilli(), "DBOS.debouncerComputeDeadline"); Object[] latestArgs = initial.args(); Duration debouncePeriod = initial.debouncePeriod(); while (true) { - long now = dbos.runStep(() -> Instant.now().toEpochMilli(), "DBOS.debouncerNow"); - if (now >= deadlineEpochMs) { + long nowEpochMs = dbos.runStep(() -> Instant.now().toEpochMilli(), "DBOS.debouncerNow"); + Duration remaining = Duration.ofMillis(deadlineEpochMs - nowEpochMs); + if (remaining.compareTo(Duration.ZERO) <= 0) { break; } - long timeUntilDeadlineMs = Math.max(deadlineEpochMs - now, 0); Duration waitDuration = - debouncePeriod.toMillis() <= timeUntilDeadlineMs - ? debouncePeriod - : Duration.ofMillis(timeUntilDeadlineMs); + remaining.compareTo(debouncePeriod) < 0 ? remaining : debouncePeriod; Optional msg = dbos.recv(Constants.DEBOUNCER_TOPIC, waitDuration); if (msg.isEmpty()) { From 29fc252825a567fe37778b98c2af282f71c36a14 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 15:00:04 +0300 Subject: [PATCH 11/29] Fix remaining minor PR review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove coerceArguments: DBOSJavaSerializer already writes type info for non-primitive types, so numeric mismatches don't occur; confirmed by test - Fix incorrect comment about record serialization in debouncerWorkflow - Rename debounceVoid → debounce to match the overload pattern of DBOS.runStep --- .../dev/dbos/transact/workflow/Debouncer.java | 5 ++--- .../internal/DebouncerServiceImpl.java | 18 ++---------------- .../dbos/transact/workflow/DebouncerTest.java | 8 ++++---- 3 files changed, 8 insertions(+), 23 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index d7a8326a7..d24271ec6 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -114,15 +114,14 @@ private Debouncer( } /** - * Debounce a workflow with no return value. The supplier's return value is ignored — pass {@code - * null} when no value is available. + * Debounce a workflow with no return value. * * @param debounceKey key that groups concurrent calls; calls with the same key are coalesced * @param debouncePeriod inactivity window before the user workflow runs; each call resets it * @param wfLambda lambda calling exactly one {@code @Workflow} method * @return handle to the future user workflow */ - public @NonNull WorkflowHandle debounceVoid( + public @NonNull WorkflowHandle debounce( @NonNull String debounceKey, @NonNull Duration debouncePeriod, @NonNull ThrowingRunnable wfLambda) { diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index b9fbb7d10..7cab1b916 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -3,7 +3,6 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; -import dev.dbos.transact.json.JsonUtility; import dev.dbos.transact.workflow.Workflow; import java.time.Duration; @@ -35,9 +34,8 @@ public DebouncerServiceImpl(DBOS dbos) { public void debouncerWorkflow( DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial) { - // Publish the pre-assigned user workflow id as an event so callers waiting on the - // deduplication path can retrieve it via getEvent without relying on Jackson deserializing - // workflow inputs (records are final classes so @class type info is not emitted). + // Publish the pre-assigned user workflow id as an event so callers on the deduplication path + // can retrieve it via getEvent without having to parse workflow inputs. dbos.setEvent(Constants.DEBOUNCER_CHILD_ID_KEY, ctx.userWorkflowId()); // Record the absolute deadline once as a durable step. On recovery this returns the same @@ -74,7 +72,6 @@ public void debouncerWorkflow( dbos.setEvent(next.messageId(), next.messageId()); } - // Fix 11: combine isEmpty check with orElseThrow var workflow = dbos.integration() .getRegisteredWorkflow( @@ -104,17 +101,6 @@ public void debouncerWorkflow( startOpts = startOpts.withTimeout(ctx.workflowTimeout()); } - // Coerce args to the method's declared parameter types before invocation. - // Jackson's type-info round-trip through send/recv (Object[]) can produce numeric - // mismatches (e.g. long → Integer) that cause IllegalArgumentException at reflection - // call-site. This mirrors the coercion already applied in executeWorkflowById. - try { - latestArgs = JsonUtility.coerceArguments(latestArgs, workflow.workflowMethod()); - } catch (IllegalArgumentException e) { - throw new IllegalStateException( - "Debouncer argument coercion failed for workflow " + options.workflowName(), e); - } - logger.debug( "Debouncer starting user workflow {} (id={})", options.workflowName(), diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index 1170f0983..32f8ad744 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -231,7 +231,7 @@ public void numericArgsRoundTripCorrectly() throws Exception { assertEquals(21L, result); } - // Verify that debounceVoid works for workflows with no return value. + // Verify that debounce works for workflows with no return value. public interface VoidService { void doWork(String marker); } @@ -249,15 +249,15 @@ public void doWork(String marker) { } @Test - public void debounceVoidCoalescesCorrectly() throws Exception { + public void debounceCoalescesCorrectly() throws Exception { var impl = new VoidServiceImpl(); VoidService svc = dbos.registerProxy(VoidService.class, impl); dbos.launch(); var debouncer = dbos.debouncer(); - var h1 = debouncer.debounceVoid("void-key", Duration.ofMillis(500), () -> svc.doWork("a")); + var h1 = debouncer.debounce("void-key", Duration.ofMillis(500), () -> svc.doWork("a")); Thread.sleep(100); - var h2 = debouncer.debounceVoid("void-key", Duration.ofMillis(500), () -> svc.doWork("b")); + var h2 = debouncer.debounce("void-key", Duration.ofMillis(500), () -> svc.doWork("b")); h2.getResult(); assertEquals(h1.workflowId(), h2.workflowId()); From 7d0f97325403a8715fdf524aa6b03fc84f171261 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 15:42:57 +0300 Subject: [PATCH 12/29] Add DebouncerClient for external callers without a running DBOS instance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DebouncerClient mirrors Debouncer but works via DBOSClient — no proxy capture, args passed directly, workflow identified by name + className. Implements the same deduplication/ack loop as Debouncer. Also adds DBOSClient.findWorkflowIdByDeduplicationId() and DBOSClient.debouncer(workflowName) factory, and Constants.DEBOUNCER_SERVICE_CLASS_NAME to avoid a silent coupling to the internal class name string. --- .../java/dev/dbos/transact/Constants.java | 2 + .../java/dev/dbos/transact/DBOSClient.java | 23 ++ .../dev/dbos/transact/DebouncerClient.java | 238 ++++++++++++++++++ .../transact/client/DebouncerClientTest.java | 139 ++++++++++ 4 files changed, 402 insertions(+) create mode 100644 transact/src/main/java/dev/dbos/transact/DebouncerClient.java create mode 100644 transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java diff --git a/transact/src/main/java/dev/dbos/transact/Constants.java b/transact/src/main/java/dev/dbos/transact/Constants.java index 1822c397c..5bc3be130 100644 --- a/transact/src/main/java/dev/dbos/transact/Constants.java +++ b/transact/src/main/java/dev/dbos/transact/Constants.java @@ -17,6 +17,8 @@ public class Constants { public static final String DBOS_INTERNAL_QUEUE = "_dbos_internal_queue"; public static final String DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"; + public static final String DEBOUNCER_SERVICE_CLASS_NAME = + "dev.dbos.transact.workflow.internal.DebouncerServiceImpl"; public static final String DEBOUNCER_TOPIC = "_dbos_debouncer_topic"; // Event key published by the debouncer-workflow so callers can retrieve the pre-assigned // user workflow id without relying on Jackson deserialization of workflow inputs. diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 01fdfed2e..da5b56ef8 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -747,6 +747,29 @@ public void send( return new WorkflowHandleClient<>(workflowId); } + /** + * Find the workflow ID of the active workflow with the given queue and deduplication ID. + * + * @param queueName name of the queue to search + * @param deduplicationId deduplication ID to look up + * @return the workflow ID, or {@code null} if not found + */ + public @Nullable String findWorkflowIdByDeduplicationId( + @NonNull String queueName, @NonNull String deduplicationId) { + return systemDatabase.findWorkflowIdByDeduplicationId(queueName, deduplicationId); + } + + /** + * Create a {@link DebouncerClient} that coalesces repeated calls on the same key into a single + * execution of the named workflow. + * + * @param workflowName name of the workflow function to debounce + * @return a new DebouncerClient bound to this client + */ + public @NonNull DebouncerClient debouncer(@NonNull String workflowName) { + return new DebouncerClient<>(this, workflowName); + } + /** * Cancel a worflow * diff --git a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java new file mode 100644 index 000000000..c470c6262 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java @@ -0,0 +1,238 @@ +package dev.dbos.transact; + +import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.WorkflowHandle; +import dev.dbos.transact.workflow.internal.DebouncerContextOptions; +import dev.dbos.transact.workflow.internal.DebouncerMessage; +import dev.dbos.transact.workflow.internal.DebouncerOptions; + +import java.time.Duration; +import java.util.Objects; +import java.util.UUID; + +import org.jspecify.annotations.NonNull; +import org.jspecify.annotations.Nullable; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Debounces repeated workflow invocations from an external client into a single execution using the + * most recent arguments, without requiring a running {@link DBOS} instance on the caller's side. + * + *

Create instances via {@link DBOSClient#debouncer(String)}. + * + *

Example

+ * + *
{@code
+ * var client = new DBOSClient(url, user, password);
+ *
+ * var debouncer = client.debouncer("process")
+ *     .withClassName(MyServiceImpl.class.getName())
+ *     .withDebounceTimeout(Duration.ofMinutes(5));
+ *
+ * WorkflowHandle handle =
+ *     debouncer.debounce("user-42", Duration.ofSeconds(2), "payload");
+ * String result = handle.getResult();
+ * }
+ * + * @param return type of the debounced workflow + */ +public final class DebouncerClient { + + private static final Logger logger = LoggerFactory.getLogger(DebouncerClient.class); + private static final Duration ACK_TIMEOUT = Duration.ofSeconds(1); + + private final DBOSClient client; + private final String workflowName; + private final @Nullable String className; + private final @Nullable String instanceName; + private final @Nullable String userQueueName; + private final @Nullable Duration debounceTimeout; + // Context options forwarded to the user workflow + private final @Nullable String appVersion; + private final @Nullable Integer priority; + private final @Nullable String userDeduplicationId; + private final @Nullable Duration workflowTimeout; + + DebouncerClient(@NonNull DBOSClient client, @NonNull String workflowName) { + this(client, workflowName, null, null, null, null, null, null, null, null); + } + + private DebouncerClient( + DBOSClient client, + String workflowName, + @Nullable String className, + @Nullable String instanceName, + @Nullable String userQueueName, + @Nullable Duration debounceTimeout, + @Nullable String appVersion, + @Nullable Integer priority, + @Nullable String userDeduplicationId, + @Nullable Duration workflowTimeout) { + this.client = Objects.requireNonNull(client, "client must not be null"); + this.workflowName = Objects.requireNonNull(workflowName, "workflowName must not be null"); + this.className = className; + this.instanceName = instanceName; + this.userQueueName = userQueueName; + this.debounceTimeout = debounceTimeout; + this.appVersion = appVersion; + this.priority = priority; + this.userDeduplicationId = userDeduplicationId; + this.workflowTimeout = workflowTimeout; + } + + /** Specify the Java class name of the target workflow implementation. */ + public @NonNull DebouncerClient withClassName(@Nullable String className) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId, workflowTimeout); + } + + /** Specify the DBOS instance name of the target workflow implementation. */ + public @NonNull DebouncerClient withInstanceName(@Nullable String instanceName) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId, workflowTimeout); + } + + /** + * Set the queue that the user workflow will be enqueued on when the debounce period elapses. + * {@code null} starts the user workflow directly (not enqueued). + */ + public @NonNull DebouncerClient withQueue(@Nullable String queueName) { + if (queueName != null && queueName.isEmpty()) { + throw new IllegalArgumentException("queueName must not be empty"); + } + return new DebouncerClient<>( + client, workflowName, className, instanceName, queueName, debounceTimeout, + appVersion, priority, userDeduplicationId, workflowTimeout); + } + + /** See {@link #withQueue(String)}. */ + public @NonNull DebouncerClient withQueue(@NonNull Queue queue) { + return withQueue(queue.name()); + } + + /** + * Set an absolute cap on how long the debouncer may keep absorbing calls for a single key. + * After this duration the user workflow fires even if more calls keep arriving. + */ + public @NonNull DebouncerClient withDebounceTimeout(@Nullable Duration debounceTimeout) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId, workflowTimeout); + } + + /** Target a specific application version for the user workflow. */ + public @NonNull DebouncerClient withAppVersion(@Nullable String appVersion) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId, workflowTimeout); + } + + /** Set the priority for the user workflow (only used when a queue is configured). */ + public @NonNull DebouncerClient withPriority(@Nullable Integer priority) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId, workflowTimeout); + } + + /** Set a deduplication ID to be forwarded to the user workflow. */ + public @NonNull DebouncerClient withDeduplicationId(@Nullable String deduplicationId) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, deduplicationId, workflowTimeout); + } + + /** Set a timeout for the user workflow. */ + public @NonNull DebouncerClient withTimeout(@Nullable Duration timeout) { + return new DebouncerClient<>( + client, workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId, timeout); + } + + /** + * Debounce a workflow invocation. + * + * @param debounceKey key that groups concurrent calls; calls with the same key are coalesced + * @param debouncePeriod inactivity window before the user workflow runs; each call resets it + * @param args positional arguments to pass to the user workflow + * @return handle pointing to the user workflow that will run with the latest arguments; on the + * deduplication path the handle ID is the child ID published by the running debouncer, not + * the locally generated UUID + */ + public @NonNull WorkflowHandle debounce( + @NonNull String debounceKey, @NonNull Duration debouncePeriod, Object... args) { + + Objects.requireNonNull(debounceKey, "debounceKey must not be null"); + Objects.requireNonNull(debouncePeriod, "debouncePeriod must not be null"); + if (debouncePeriod.isNegative() || debouncePeriod.isZero()) { + throw new IllegalArgumentException("debouncePeriod must be a positive non-zero duration"); + } + // className is required: DebouncerServiceImpl uses it to look up the registered workflow. + if (className == null) { + throw new IllegalStateException( + "className is required; call withClassName(MyServiceImpl.class.getName()) before debounce()"); + } + + // Not inside a workflow, so UUIDs can be generated directly (no step wrapping needed). + String userWorkflowId = UUID.randomUUID().toString(); + String messageId = UUID.randomUUID().toString(); + String deduplicationId = workflowName + "-" + debounceKey; + + DebouncerOptions debouncerOpts = + new DebouncerOptions(workflowName, className, instanceName, userQueueName, debounceTimeout); + DebouncerContextOptions ctx = + new DebouncerContextOptions( + userWorkflowId, userDeduplicationId, priority, appVersion, workflowTimeout); + DebouncerMessage initial = new DebouncerMessage(messageId, args, debouncePeriod); + + // Use the stable class-name constant so a rename of DebouncerServiceImpl is caught at compile time. + var enqueueOpts = + new DBOSClient.EnqueueOptions( + Constants.DEBOUNCER_WORKFLOW_NAME, + Constants.DEBOUNCER_SERVICE_CLASS_NAME, + Constants.DBOS_INTERNAL_QUEUE) + .withDeduplicationId(deduplicationId); + + while (true) { + try { + client.enqueueWorkflow(enqueueOpts, new Object[] {debouncerOpts, ctx, initial}); + return client.retrieveWorkflow(userWorkflowId); + } catch (DBOSQueueDuplicatedException dup) { + // A debouncer for this key is already running — forward the latest args to it. + String existingDebouncerId = + client.findWorkflowIdByDeduplicationId( + Constants.DBOS_INTERNAL_QUEUE, deduplicationId); + if (existingDebouncerId == null) { + logger.debug( + "Debouncer for dedupId {} not found after conflict; retrying", deduplicationId); + continue; + } + + DebouncerMessage msg = new DebouncerMessage(messageId, args, debouncePeriod); + client.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC, messageId); + + var ack = client.getEvent(existingDebouncerId, messageId, ACK_TIMEOUT); + if (ack.isEmpty()) { + logger.debug( + "Debouncer {} did not ack message {}; retrying", existingDebouncerId, messageId); + continue; + } + + // DEBOUNCER_CHILD_ID_KEY is published as the debouncer's first action, before the + // recv-loop. If the ack arrived the event should be available; retry if not to guard + // against transient delays. + var childIdOpt = + client.getEvent(existingDebouncerId, Constants.DEBOUNCER_CHILD_ID_KEY, ACK_TIMEOUT); + if (childIdOpt.isEmpty()) { + logger.debug( + "DEBOUNCER_CHILD_ID_KEY not yet available from {}; retrying", existingDebouncerId); + continue; + } + return client.retrieveWorkflow((String) childIdOpt.get()); + } + } + } +} diff --git a/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java b/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java new file mode 100644 index 000000000..c7290039a --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java @@ -0,0 +1,139 @@ +package dev.dbos.transact.client; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.dbos.transact.DBOS; +import dev.dbos.transact.DBOSClient; +import dev.dbos.transact.DebouncerClient; +import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.utils.PgContainer; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.Workflow; +import dev.dbos.transact.workflow.WorkflowStatus; +import dev.dbos.transact.workflow.WorkflowState; + +import java.time.Duration; +import java.util.concurrent.ConcurrentLinkedQueue; +import java.util.concurrent.atomic.AtomicInteger; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AutoClose; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +interface ClientTargetService { + String process(String input); +} + +class ClientTargetServiceImpl implements ClientTargetService { + final AtomicInteger callCount = new AtomicInteger(); + final ConcurrentLinkedQueue callArgs = new ConcurrentLinkedQueue<>(); + + @Override + @Workflow + public String process(String input) { + callCount.incrementAndGet(); + callArgs.add(input); + return "result:" + input; + } +} + +public class DebouncerClientTest { + + @AutoClose final PgContainer pgContainer = new PgContainer(); + + DBOSConfig dbosConfig; + @AutoClose DBOS dbos; + @AutoClose HikariDataSource dataSource; + @AutoClose DBOSClient dbosClient; + + static final Queue USER_QUEUE = new Queue("client-user-queue"); + + ClientTargetServiceImpl serviceImpl; + + @BeforeEach + void beforeEach() { + dbosConfig = pgContainer.dbosConfig(); + dbos = new DBOS(dbosConfig); + dataSource = pgContainer.dataSource(); + + serviceImpl = new ClientTargetServiceImpl(); + dbos.registerProxy(ClientTargetService.class, serviceImpl); + dbos.registerQueue(USER_QUEUE); + dbos.launch(); + + dbosClient = new DBOSClient(pgContainer.jdbcUrl(), pgContainer.username(), pgContainer.password()); + } + + private DebouncerClient debouncer() { + return dbosClient.debouncer("process") + .withClassName(ClientTargetServiceImpl.class.getName()); + } + + @Test + void singleCallFiresOnce() throws Exception { + var handle = debouncer().debounce("key-1", Duration.ofMillis(500), "hello"); + assertEquals("result:hello", handle.getResult()); + assertEquals(1, serviceImpl.callCount.get()); + } + + @Test + void multipleCallsCoalesceToLatestArgs() throws Exception { + var d = debouncer(); + // Use a long period (3s) so the window cannot close between the three calls even on slow CI. + var h1 = d.debounce("key-2", Duration.ofSeconds(3), "v1"); + Thread.sleep(100); + var h2 = d.debounce("key-2", Duration.ofSeconds(3), "v2"); + Thread.sleep(100); + var h3 = d.debounce("key-2", Duration.ofSeconds(3), "v3"); + + String result = h3.getResult(); + assertEquals("result:v3", result); + assertEquals(h1.workflowId(), h2.workflowId()); + assertEquals(h2.workflowId(), h3.workflowId()); + assertEquals(1, serviceImpl.callCount.get()); + } + + @Test + void differentKeysFireIndependently() throws Exception { + var d = debouncer(); + var hA = d.debounce("key-A", Duration.ofMillis(400), "A"); + var hB = d.debounce("key-B", Duration.ofMillis(400), "B"); + + assertNotEquals(hA.workflowId(), hB.workflowId()); + assertEquals("result:A", hA.getResult()); + assertEquals("result:B", hB.getResult()); + assertEquals(2, serviceImpl.callCount.get()); + } + + @Test + void reDebouncAfterWindowCloses() throws Exception { + var d = debouncer(); + + var h1 = d.debounce("key-r", Duration.ofMillis(300), "first"); + assertEquals("result:first", h1.getResult()); + assertEquals(1, serviceImpl.callCount.get()); + + Thread.sleep(200); + + var h2 = d.debounce("key-r", Duration.ofMillis(300), "second"); + assertEquals("result:second", h2.getResult()); + assertEquals(2, serviceImpl.callCount.get()); + + assertNotEquals(h1.workflowId(), h2.workflowId()); + } + + @Test + void debouncerClientWithQueue() throws Exception { + var handle = debouncer() + .withQueue(USER_QUEUE) + .debounce("key-q", Duration.ofMillis(400), "queued"); + + assertEquals("result:queued", handle.getResult()); + + var status = dbosClient.getWorkflowStatus(handle.workflowId()).orElseThrow(); + assertEquals(WorkflowState.SUCCESS, status.status()); + assertEquals(USER_QUEUE.name(), status.queueName()); + assertEquals(1, serviceImpl.callCount.get()); + } +} From 6968e56e0881edda8256dcf158793a76f61ced06 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 16:22:22 +0300 Subject: [PATCH 13/29] Remove appVersion/priority/deduplicationId from DBOSContext These are enqueue-time options, not execution-time context. Passing them through DBOSContext creates ambient state that is inconsistent with Java's explicit builder style and does not match Python's actual behavior either: Python's DBOSContext.priority is only set via SetEnqueueOptions and is not auto-restored from the database when a workflow starts executing. - Move appVersion, priority, deduplicationId to DebouncerOptions so they are configured explicitly via Debouncer.withAppVersion/Priority/DeduplicationId - Simplify DebouncerContextOptions to userWorkflowId + workflowTimeout only - Revert DBOSExecutor context creation and validation to pre-PR form - Rename test to reflect that priority is now set explicitly on the debouncer --- .../dev/dbos/transact/DebouncerClient.java | 8 +-- .../dbos/transact/context/DBOSContext.java | 53 +--------------- .../dbos/transact/execution/DBOSExecutor.java | 37 ++++------- .../dev/dbos/transact/workflow/Debouncer.java | 61 ++++++++++++------- .../internal/DebouncerContextOptions.java | 12 ++-- .../workflow/internal/DebouncerOptions.java | 7 ++- .../internal/DebouncerServiceImpl.java | 6 +- .../dbos/transact/workflow/DebouncerTest.java | 14 ++--- 8 files changed, 75 insertions(+), 123 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java index c470c6262..e1cbcea05 100644 --- a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java +++ b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java @@ -182,10 +182,10 @@ private DebouncerClient( String deduplicationId = workflowName + "-" + debounceKey; DebouncerOptions debouncerOpts = - new DebouncerOptions(workflowName, className, instanceName, userQueueName, debounceTimeout); - DebouncerContextOptions ctx = - new DebouncerContextOptions( - userWorkflowId, userDeduplicationId, priority, appVersion, workflowTimeout); + new DebouncerOptions( + workflowName, className, instanceName, userQueueName, debounceTimeout, + appVersion, priority, userDeduplicationId); + DebouncerContextOptions ctx = new DebouncerContextOptions(userWorkflowId, workflowTimeout); DebouncerMessage initial = new DebouncerMessage(messageId, args, debouncePeriod); // Use the stable class-name constant so a rename of DebouncerServiceImpl is caught at compile time. diff --git a/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java b/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java index 95511c51a..499e69b16 100644 --- a/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java +++ b/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java @@ -23,9 +23,6 @@ public class DBOSContext { private final Duration timeout; private final Instant deadline; private SerializationStrategy serialization; - private final Integer priority; - private final String appVersion; - private final String deduplicationId; // private StepStatus stepStatus; @@ -36,13 +33,10 @@ public DBOSContext() { timeout = null; deadline = null; serialization = SerializationStrategy.DEFAULT; - priority = null; - appVersion = null; - deduplicationId = null; } public DBOSContext(String workflowId, WorkflowInfo parent, Duration timeout, Instant deadline) { - this(workflowId, parent, timeout, deadline, null, null, null, null); + this(workflowId, parent, timeout, deadline, null); } public DBOSContext( @@ -51,27 +45,12 @@ public DBOSContext( Duration timeout, Instant deadline, SerializationStrategy serialization) { - this(workflowId, parent, timeout, deadline, serialization, null, null, null); - } - - public DBOSContext( - String workflowId, - WorkflowInfo parent, - Duration timeout, - Instant deadline, - SerializationStrategy serialization, - Integer priority, - String appVersion, - String deduplicationId) { this.workflowId = workflowId; this.functionId = 0; this.parent = parent; this.timeout = timeout; this.deadline = deadline; this.serialization = serialization; - this.priority = priority; - this.appVersion = appVersion; - this.deduplicationId = deduplicationId; } public DBOSContext( @@ -89,9 +68,6 @@ public DBOSContext( this.timeout = other.timeout; this.deadline = other.deadline; this.serialization = other.serialization; - this.priority = other.priority; - this.appVersion = other.appVersion; - this.deduplicationId = other.deduplicationId; } public boolean isInWorkflow() { @@ -192,31 +168,4 @@ public static SerializationStrategy serializationStrategy() { var ctx = DBOSContextHolder.get(); return ctx != null ? ctx.getSerialization() : null; } - - public Integer getPriority() { - return priority; - } - - public String getAppVersion() { - return appVersion; - } - - public String getDeduplicationId() { - return deduplicationId; - } - - public static Integer currentPriority() { - var ctx = DBOSContextHolder.get(); - return ctx != null ? ctx.priority : null; - } - - public static String currentAppVersion() { - var ctx = DBOSContextHolder.get(); - return ctx != null ? ctx.appVersion : null; - } - - public static String currentDeduplicationId() { - var ctx = DBOSContextHolder.get(); - return ctx != null ? ctx.deduplicationId : 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 97760cc28..be530d512 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -1353,13 +1353,9 @@ public WorkflowHandle executeWorkflowById( throw e; } - // Restore queue-related metadata so the workflow context carries them during execution. - // This is needed for context propagation (e.g. priority forwarded to child workflows). var options = new ExecutionOptions(workflowId, status.timeout(), status.deadline()) - .withSerialization(status.serialization()) - .withPriority(status.priority()) - .withAppVersion(status.appVersion()); + .withSerialization(status.serialization()); if (isRecoveryRequest) options = options.asRecoveryRequest(); if (isDequeuedRequest) options = options.asDequeuedRequest(); return executeWorkflow(workflow, inputs, options, null); @@ -1489,22 +1485,18 @@ private WorkflowHandle executeWorkflow( return new WorkflowHandleDBPoll<>(this, workflowId); } - // For dequeued or recovered workflows, queue-related options are restored from - // workflow_status for context propagation. Skip the validation that would reject them. var badOptionList = new ArrayList(); - if (!options.isDequeuedRequest() && !options.isRecoveryRequest()) { - if (options.deduplicationId() != null) { - badOptionList.add("deduplicationId"); - } - if (options.priority() != null) { - badOptionList.add("priority"); - } - if (options.queuePartitionKey() != null) { - badOptionList.add("queuePartitionKey"); - } - if (options.delay() != null) { - badOptionList.add("delay"); - } + if (options.deduplicationId() != null) { + badOptionList.add("deduplicationId"); + } + if (options.priority() != null) { + badOptionList.add("priority"); + } + if (options.queuePartitionKey() != null) { + badOptionList.add("queuePartitionKey"); + } + if (options.delay() != null) { + badOptionList.add("delay"); } if (!badOptionList.isEmpty()) { @@ -1574,10 +1566,7 @@ private WorkflowHandle executeWorkflow( finalOptions.deadline(), SerializationUtil.PORTABLE.equals(initResult.serialization()) ? SerializationStrategy.PORTABLE - : SerializationStrategy.DEFAULT, - finalOptions.priority(), - finalOptions.appVersion(), - finalOptions.deduplicationId())); + : SerializationStrategy.DEFAULT)); if (Thread.currentThread().isInterrupted()) { logger.debug("executeWorkflow task interrupted before workflow.invoke"); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index d24271ec6..885adea90 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -3,7 +3,6 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; -import dev.dbos.transact.context.DBOSContext; import dev.dbos.transact.context.DBOSContextHolder; import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; import dev.dbos.transact.execution.RegisteredWorkflow; @@ -71,21 +70,30 @@ private record DebounceIds(String userWorkflowId, String messageId) {} private final RegisteredWorkflow debouncerWorkflow; private final @Nullable String queueName; private final @Nullable Duration debounceTimeout; + private final @Nullable String appVersion; + private final @Nullable Integer priority; + private final @Nullable String deduplicationId; public Debouncer(@NonNull DBOS dbos, @NonNull RegisteredWorkflow debouncerWorkflow) { - this(dbos, debouncerWorkflow, null, null); + this(dbos, debouncerWorkflow, null, null, null, null, null); } private Debouncer( DBOS dbos, RegisteredWorkflow debouncerWorkflow, @Nullable String queueName, - @Nullable Duration debounceTimeout) { + @Nullable Duration debounceTimeout, + @Nullable String appVersion, + @Nullable Integer priority, + @Nullable String deduplicationId) { this.dbos = Objects.requireNonNull(dbos, "dbos must not be null"); this.debouncerWorkflow = Objects.requireNonNull(debouncerWorkflow, "debouncerWorkflow must not be null"); this.queueName = queueName; this.debounceTimeout = debounceTimeout; + this.appVersion = appVersion; + this.priority = priority; + this.deduplicationId = deduplicationId; } /** @@ -96,7 +104,7 @@ private Debouncer( if (queueName != null && queueName.isEmpty()) { throw new IllegalArgumentException("queueName must not be empty"); } - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout); + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** See {@link #withQueue(String)}. */ @@ -110,7 +118,22 @@ private Debouncer( * arriving. */ public @NonNull Debouncer withDebounceTimeout(@Nullable Duration debounceTimeout) { - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout); + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + } + + /** Target a specific application version for the user workflow. */ + public @NonNull Debouncer withAppVersion(@Nullable String appVersion) { + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + } + + /** Set the priority for the user workflow (only applies when a queue is configured). */ + public @NonNull Debouncer withPriority(@Nullable Integer priority) { + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + } + + /** Set a deduplication ID to be forwarded to the user workflow. */ + public @NonNull Debouncer withDeduplicationId(@Nullable String deduplicationId) { + return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** @@ -176,27 +199,21 @@ private WorkflowHandle debounceInternal( } String userWorkflowId = ids.userWorkflowId(); String messageId = ids.messageId(); - String deduplicationId = invocation.workflowName() + "-" + debounceKey; + String debouncerDeduplicationId = invocation.workflowName() + "-" + debounceKey; - // Fix 4: pass Duration directly instead of converting to millis DebouncerOptions options = new DebouncerOptions( invocation.workflowName(), invocation.className(), invocation.instanceName(), queueName, - debounceTimeout); - // Fix 8: DBOSContextHolder.get() is guaranteed non-null inside a workflow context - // Propagate the calling workflow's context (priority, timeout, appVersion, deduplicationId) - // to the user workflow — mirrors Python's ContextOptions snapshot. + debounceTimeout, + appVersion, + priority, + deduplicationId); + // DBOSContextHolder.get() is guaranteed non-null inside a workflow context Duration workflowTimeout = DBOS.inWorkflow() ? DBOSContextHolder.get().getTimeout() : null; - DebouncerContextOptions ctx = - new DebouncerContextOptions( - userWorkflowId, - DBOSContext.currentDeduplicationId(), - DBOSContext.currentPriority(), - DBOSContext.currentAppVersion(), - workflowTimeout); + DebouncerContextOptions ctx = new DebouncerContextOptions(userWorkflowId, workflowTimeout); DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), debouncePeriod); while (true) { @@ -204,7 +221,7 @@ private WorkflowHandle debounceInternal( var startOpts = new StartWorkflowOptions() .withQueue(Constants.DBOS_INTERNAL_QUEUE) - .withDeduplicationId(deduplicationId); + .withDeduplicationId(debouncerDeduplicationId); // Fix 5: use startRegisteredWorkflow instead of startWorkflow with proxy lambda dbos.integration() .startRegisteredWorkflow( @@ -218,13 +235,13 @@ private WorkflowHandle debounceInternal( // deterministic. Mirrors Python's call_function_as_step("DBOS.get_deduplicated_workflow"). String existingDebouncerId = (DBOS.inWorkflow() && !DBOS.inStep()) - ? dbos.runStep(() -> lookupExistingDebouncerId(deduplicationId), "lookupDebouncer") - : lookupExistingDebouncerId(deduplicationId); + ? dbos.runStep(() -> lookupExistingDebouncerId(debouncerDeduplicationId), "lookupDebouncer") + : lookupExistingDebouncerId(debouncerDeduplicationId); if (existingDebouncerId == null) { // The existing debouncer finished between the enqueue attempt and now. Retry from // scratch — the next enqueue should succeed. logger.debug( - "Debouncer for dedupId {} not found after conflict; retrying", deduplicationId); + "Debouncer for dedupId {} not found after conflict; retrying", debouncerDeduplicationId); continue; } DebouncerMessage msg = diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java index 3c1f0975f..ce087dd90 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java @@ -6,15 +6,15 @@ import org.jspecify.annotations.Nullable; /** - * Context captured from the caller of {@code Debouncer.debounce()} and forwarded to the user - * workflow when it is eventually started. The {@code userWorkflowId} is pre-assigned by the caller - * so that the caller can return a handle pointing to the future workflow. + * Per-call context forwarded from the caller of {@code Debouncer.debounce()} to the debouncer + * service workflow. Contains only values that are inherently call-specific: the pre-assigned user + * workflow ID and the caller's active timeout (if any). + * + *

Enqueue-time options (appVersion, priority, deduplicationId) are carried in + * {@link DebouncerOptions} instead, where they are set explicitly via the Debouncer builder. * *

Not part of the public API. */ public record DebouncerContextOptions( @NonNull String userWorkflowId, - @Nullable String deduplicationId, - @Nullable Integer priority, - @Nullable String appVersion, @Nullable Duration workflowTimeout) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java index 11b4512f4..e710e2486 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerOptions.java @@ -7,7 +7,7 @@ /** * Inputs to the debouncer service workflow that identify the user workflow to be eventually started - * and the optional absolute timeout cap. + * and configures how it should be enqueued. * *

Not part of the public API. */ @@ -16,4 +16,7 @@ public record DebouncerOptions( @NonNull String className, @Nullable String instanceName, @Nullable String queueName, - @Nullable Duration debounceTimeout) {} + @Nullable Duration debounceTimeout, + @Nullable String appVersion, + @Nullable Integer priority, + @Nullable String deduplicationId) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index 7cab1b916..485b3dbcc 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -94,9 +94,9 @@ public void debouncerWorkflow( new StartWorkflowOptions() .withWorkflowId(ctx.userWorkflowId()) .withQueue(options.queueName()) - .withDeduplicationId(hasQueue ? ctx.deduplicationId() : null) - .withPriority(hasQueue ? ctx.priority() : null) - .withAppVersion(ctx.appVersion()); + .withDeduplicationId(hasQueue ? options.deduplicationId() : null) + .withPriority(hasQueue ? options.priority() : null) + .withAppVersion(options.appVersion()); if (ctx.workflowTimeout() != null) { startOpts = startOpts.withTimeout(ctx.workflowTimeout()); } diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index 32f8ad744..79ae24a84 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -284,8 +284,6 @@ public void absoluteTimeoutUsesLatestArgs() throws Exception { assertEquals(List.of("last"), serviceImpl.callArgs()); } - // Wrapper workflow that calls debounce() internally so caller context (priority) - // is available via DBOSContext when DebouncerContextOptions is built. public interface OrchestratorService { String debounceWithPriority(String arg); } @@ -304,17 +302,17 @@ public OrchestratorServiceImpl(DBOS dbos, DebouncedService svc, Queue userQueue) @Override @Workflow public String debounceWithPriority(String arg) { - // withQueue ensures priority is forwarded (priority is only valid for queued workflows). return dbos.debouncer() .withQueue(userQueue) + .withPriority(42) .debounce("prio-inner", Duration.ofMillis(400), () -> svc.process(arg)) .getResult(); } } - // Verify that priority from caller workflow context is propagated to the user workflow. + // Verify that explicit withPriority() on Debouncer is forwarded to the user workflow. @Test - public void priorityPropagatedFromCallerContext() throws Exception { + public void explicitPriorityForwardedToUserWorkflow() throws Exception { Queue q = new Queue("prio-queue").withPriorityEnabled(true); dbos.registerQueue(q); DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); @@ -322,13 +320,9 @@ public void priorityPropagatedFromCallerContext() throws Exception { dbos.registerProxy(OrchestratorService.class, new OrchestratorServiceImpl(dbos, svc, q)); dbos.launch(); - // Start the orchestrator with priority=42. - var opts = new StartWorkflowOptions().withQueue(q).withPriority(42); - var h = dbos.startWorkflow(() -> orch.debounceWithPriority("prio-val"), opts); + var h = dbos.startWorkflow(() -> orch.debounceWithPriority("prio-val")); assertEquals("result:prio-val", h.getResult()); - // The user workflow started by the debouncer should have inherited priority=42. - // It runs on queue q, so priority is stored in workflow_status. var userWfStatus = dbos .listWorkflows( From 9d649d1d57a8c32b64a810dbf1065f64f7daca9e Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 16:43:47 +0300 Subject: [PATCH 14/29] Give Debouncer a direct DBOSExecutor reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace dbos.integration().captureInvocation/findWorkflowIdByDeduplicationId/ startRegisteredWorkflow with direct executor calls - Use executor.runStep() for assignDebounceIds and lookupDebouncer steps - Use DBOSContext.getNextWorkflowId() for userWorkflowId so callers can pre-specify it via WorkflowOptions - Remove captureInvocation and findWorkflowIdByDeduplicationId from DBOSIntegration — they were only added for Debouncer --- .../src/main/java/dev/dbos/transact/DBOS.java | 2 +- .../transact/internal/DBOSIntegration.java | 48 --------------- .../dev/dbos/transact/workflow/Debouncer.java | 58 +++++++++++-------- 3 files changed, 34 insertions(+), 74 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 776f75a06..afa5a2692 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -419,7 +419,7 @@ public void sleep(@NonNull Duration duration) { * @return a fresh debouncer bound to this DBOS instance */ public @NonNull Debouncer debouncer() { - return new Debouncer<>(this, debouncerWorkflow); + return new Debouncer<>(this, ensureLaunched("debouncer"), debouncerWorkflow); } /** diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 5dd6a63d6..36227b4a3 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -8,7 +8,6 @@ import dev.dbos.transact.execution.DBOSLifecycleListener; import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.RegisteredWorkflowInstance; -import dev.dbos.transact.execution.ThrowingSupplier; import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowHandle; @@ -156,53 +155,6 @@ public RegisteredWorkflow registerWorkflow( serializationStrategy); } - /** - * Captured information about a workflow invocation: the workflow name, declaring class name, - * optional instance name, and positional arguments. - */ - public record CapturedInvocation( - @NonNull String workflowName, - @NonNull String className, - @Nullable String instanceName, - @NonNull Object[] args) {} - - /** - * Capture the workflow invocation triggered by the supplied lambda without executing the - * workflow. The lambda must call exactly one {@code @Workflow} method on a registered proxy. This - * is intended for infrastructure that needs to defer a workflow start (for example, the - * debouncer). - * - * @param wfLambda lambda that invokes exactly one workflow method on a registered proxy - * @return the captured workflow name, class name, instance name, and arguments - * @throws IllegalStateException if DBOS has not been launched - */ - public CapturedInvocation captureInvocation( - @NonNull ThrowingSupplier wfLambda) { - var invocation = executor("captureInvocation").captureInvocation(wfLambda); - return new CapturedInvocation( - invocation.workflowName(), - invocation.className(), - invocation.instanceName(), - invocation.args()); - } - - /** - * Find the workflow ID of the currently-active workflow with a given queue and deduplication ID. - * Uses the unique {@code (queue_name, deduplication_id)} index for an O(1) point lookup. Returns - * {@code null} if no active (ENQUEUED, DELAYED, or PENDING) workflow with that deduplication ID - * exists in the given queue. - * - * @param queueName name of the queue to search - * @param deduplicationId deduplication ID to look up - * @return the workflow ID, or {@code null} if not found - * @throws IllegalStateException if DBOS has not been launched - */ - public @Nullable String findWorkflowIdByDeduplicationId( - @NonNull String queueName, @NonNull String deduplicationId) { - return executor("findWorkflowIdByDeduplicationId") - .findWorkflowIdByDeduplicationId(queueName, deduplicationId); - } - /** * Start or enqueue a workflow by its {@link RegisteredWorkflow} registration. Intended for use by * event listeners and other infrastructure that dispatches workflows by registration rather than diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 885adea90..bfecde40c 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -5,10 +5,11 @@ import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.context.DBOSContextHolder; import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; +import dev.dbos.transact.execution.DBOSExecutor; import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.ThrowingRunnable; import dev.dbos.transact.execution.ThrowingSupplier; -import dev.dbos.transact.internal.DBOSIntegration; +import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.internal.DebouncerContextOptions; import dev.dbos.transact.workflow.internal.DebouncerMessage; import dev.dbos.transact.workflow.internal.DebouncerOptions; @@ -63,10 +64,10 @@ public final class Debouncer { */ private static final Duration ACK_TIMEOUT = Duration.ofSeconds(1); - // Fix 9: record for carrying the two pre-assigned IDs out of a durable step private record DebounceIds(String userWorkflowId, String messageId) {} private final DBOS dbos; + private final DBOSExecutor executor; private final RegisteredWorkflow debouncerWorkflow; private final @Nullable String queueName; private final @Nullable Duration debounceTimeout; @@ -74,12 +75,16 @@ private record DebounceIds(String userWorkflowId, String messageId) {} private final @Nullable Integer priority; private final @Nullable String deduplicationId; - public Debouncer(@NonNull DBOS dbos, @NonNull RegisteredWorkflow debouncerWorkflow) { - this(dbos, debouncerWorkflow, null, null, null, null, null); + public Debouncer( + @NonNull DBOS dbos, + @NonNull DBOSExecutor executor, + @NonNull RegisteredWorkflow debouncerWorkflow) { + this(dbos, executor, debouncerWorkflow, null, null, null, null, null); } private Debouncer( DBOS dbos, + DBOSExecutor executor, RegisteredWorkflow debouncerWorkflow, @Nullable String queueName, @Nullable Duration debounceTimeout, @@ -87,6 +92,7 @@ private Debouncer( @Nullable Integer priority, @Nullable String deduplicationId) { this.dbos = Objects.requireNonNull(dbos, "dbos must not be null"); + this.executor = Objects.requireNonNull(executor, "executor must not be null"); this.debouncerWorkflow = Objects.requireNonNull(debouncerWorkflow, "debouncerWorkflow must not be null"); this.queueName = queueName; @@ -104,7 +110,7 @@ private Debouncer( if (queueName != null && queueName.isEmpty()) { throw new IllegalArgumentException("queueName must not be empty"); } - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** See {@link #withQueue(String)}. */ @@ -118,22 +124,22 @@ private Debouncer( * arriving. */ public @NonNull Debouncer withDebounceTimeout(@Nullable Duration debounceTimeout) { - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** Target a specific application version for the user workflow. */ public @NonNull Debouncer withAppVersion(@Nullable String appVersion) { - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** Set the priority for the user workflow (only applies when a queue is configured). */ public @NonNull Debouncer withPriority(@Nullable Integer priority) { - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** Set a deduplication ID to be forwarded to the user workflow. */ public @NonNull Debouncer withDeduplicationId(@Nullable String deduplicationId) { - return new Debouncer<>(dbos, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); } /** @@ -184,18 +190,24 @@ private WorkflowHandle debounceInternal( throw new IllegalArgumentException("debouncePeriod must be a positive non-zero duration"); } - DBOSIntegration.CapturedInvocation invocation = dbos.integration().captureInvocation(wfLambda); + DBOSExecutor.Invocation invocation = executor.captureInvocation(wfLambda); - // Fix 9: use a record to carry both IDs out of a single durable step, eliminating - // the "|"-join/split hack. Inside a workflow the step makes replay deterministic. + // Inside a workflow, ID generation is wrapped in a step so replay is deterministic. DebounceIds ids; if (DBOS.inWorkflow() && !DBOS.inStep()) { ids = - dbos.runStep( - () -> new DebounceIds(UUID.randomUUID().toString(), UUID.randomUUID().toString()), - "assignDebounceIds"); + executor.runStep( + () -> + new DebounceIds( + DBOSContextHolder.get().getNextWorkflowId(UUID.randomUUID().toString()), + UUID.randomUUID().toString()), + new StepOptions("assignDebounceIds"), + null); } else { - ids = new DebounceIds(UUID.randomUUID().toString(), UUID.randomUUID().toString()); + ids = + new DebounceIds( + DBOSContextHolder.get().getNextWorkflowId(UUID.randomUUID().toString()), + UUID.randomUUID().toString()); } String userWorkflowId = ids.userWorkflowId(); String messageId = ids.messageId(); @@ -222,10 +234,8 @@ private WorkflowHandle debounceInternal( new StartWorkflowOptions() .withQueue(Constants.DBOS_INTERNAL_QUEUE) .withDeduplicationId(debouncerDeduplicationId); - // Fix 5: use startRegisteredWorkflow instead of startWorkflow with proxy lambda - dbos.integration() - .startRegisteredWorkflow( - debouncerWorkflow, new Object[] {options, ctx, initial}, startOpts); + executor.startRegisteredWorkflow( + debouncerWorkflow, new Object[] {options, ctx, initial}, startOpts); // Successfully enqueued a fresh debouncer for this key. return dbos.retrieveWorkflow(userWorkflowId); } catch (DBOSQueueDuplicatedException dup) { @@ -235,7 +245,7 @@ private WorkflowHandle debounceInternal( // deterministic. Mirrors Python's call_function_as_step("DBOS.get_deduplicated_workflow"). String existingDebouncerId = (DBOS.inWorkflow() && !DBOS.inStep()) - ? dbos.runStep(() -> lookupExistingDebouncerId(debouncerDeduplicationId), "lookupDebouncer") + ? executor.runStep(() -> lookupExistingDebouncerId(debouncerDeduplicationId), new StepOptions("lookupDebouncer"), null) : lookupExistingDebouncerId(debouncerDeduplicationId); if (existingDebouncerId == null) { // The existing debouncer finished between the enqueue attempt and now. Retry from @@ -246,8 +256,7 @@ private WorkflowHandle debounceInternal( } DebouncerMessage msg = new DebouncerMessage(messageId, invocation.args(), debouncePeriod); - // Fix 7: use messageId as idempotency key — the send overload guarantees exactly-once - // delivery without needing a separate messageSent tracking flag. + // messageId is the idempotency key — exactly-once delivery, no tracking flag needed. dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC, messageId); // Wait for the debouncer to acknowledge receipt. If the debouncer exited before @@ -276,7 +285,6 @@ private WorkflowHandle debounceInternal( } private @Nullable String lookupExistingDebouncerId(String deduplicationId) { - return dbos.integration() - .findWorkflowIdByDeduplicationId(Constants.DBOS_INTERNAL_QUEUE, deduplicationId); + return executor.findWorkflowIdByDeduplicationId(Constants.DBOS_INTERNAL_QUEUE, deduplicationId); } } From 4b79d279fb51c55db70fde8f1b88870dfd985890 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 21 May 2026 08:52:01 -0700 Subject: [PATCH 15/29] spotlessApply --- .../src/main/java/dev/dbos/transact/DBOS.java | 6 +- .../dev/dbos/transact/DebouncerClient.java | 116 ++++++++++++++---- .../dev/dbos/transact/workflow/Debouncer.java | 62 ++++++++-- .../internal/DebouncerContextOptions.java | 7 +- .../internal/DebouncerServiceImpl.java | 3 +- .../transact/client/DebouncerClientTest.java | 12 +- .../dbos/transact/workflow/DebouncerTest.java | 1 - 7 files changed, 159 insertions(+), 48 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index afa5a2692..3255bbd4d 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -4,6 +4,7 @@ import dev.dbos.transact.context.DBOSContext; import dev.dbos.transact.execution.DBOSExecutor; import dev.dbos.transact.execution.DBOSLifecycleListener; +import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.ThrowingRunnable; import dev.dbos.transact.execution.ThrowingSupplier; import dev.dbos.transact.internal.DBOSIntegration; @@ -22,7 +23,6 @@ import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.VersionInfo; import dev.dbos.transact.workflow.Workflow; -import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.workflow.WorkflowDelay; import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowSchedule; @@ -96,7 +96,9 @@ public DBOS(@NonNull DBOSConfig config) { for (var m : DebouncerServiceImpl.class.getDeclaredMethods()) { if (m.isAnnotationPresent(dev.dbos.transact.workflow.Workflow.class)) { m.setAccessible(true); - rw = integration.registerWorkflow(m.getAnnotation(dev.dbos.transact.workflow.Workflow.class), debouncerImpl, m, null); + rw = + integration.registerWorkflow( + m.getAnnotation(dev.dbos.transact.workflow.Workflow.class), debouncerImpl, m, null); break; } } diff --git a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java index e1cbcea05..ad00cd998 100644 --- a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java +++ b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java @@ -85,15 +85,31 @@ private DebouncerClient( /** Specify the Java class name of the target workflow implementation. */ public @NonNull DebouncerClient withClassName(@Nullable String className) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + workflowTimeout); } /** Specify the DBOS instance name of the target workflow implementation. */ public @NonNull DebouncerClient withInstanceName(@Nullable String instanceName) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + workflowTimeout); } /** @@ -105,8 +121,16 @@ private DebouncerClient( throw new IllegalArgumentException("queueName must not be empty"); } return new DebouncerClient<>( - client, workflowName, className, instanceName, queueName, debounceTimeout, - appVersion, priority, userDeduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + queueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + workflowTimeout); } /** See {@link #withQueue(String)}. */ @@ -115,41 +139,81 @@ private DebouncerClient( } /** - * Set an absolute cap on how long the debouncer may keep absorbing calls for a single key. - * After this duration the user workflow fires even if more calls keep arriving. + * Set an absolute cap on how long the debouncer may keep absorbing calls for a single key. After + * this duration the user workflow fires even if more calls keep arriving. */ public @NonNull DebouncerClient withDebounceTimeout(@Nullable Duration debounceTimeout) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + workflowTimeout); } /** Target a specific application version for the user workflow. */ public @NonNull DebouncerClient withAppVersion(@Nullable String appVersion) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + workflowTimeout); } /** Set the priority for the user workflow (only used when a queue is configured). */ public @NonNull DebouncerClient withPriority(@Nullable Integer priority) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + workflowTimeout); } /** Set a deduplication ID to be forwarded to the user workflow. */ public @NonNull DebouncerClient withDeduplicationId(@Nullable String deduplicationId) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, deduplicationId, workflowTimeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + deduplicationId, + workflowTimeout); } /** Set a timeout for the user workflow. */ public @NonNull DebouncerClient withTimeout(@Nullable Duration timeout) { return new DebouncerClient<>( - client, workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId, timeout); + client, + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId, + timeout); } /** @@ -183,12 +247,19 @@ private DebouncerClient( DebouncerOptions debouncerOpts = new DebouncerOptions( - workflowName, className, instanceName, userQueueName, debounceTimeout, - appVersion, priority, userDeduplicationId); + workflowName, + className, + instanceName, + userQueueName, + debounceTimeout, + appVersion, + priority, + userDeduplicationId); DebouncerContextOptions ctx = new DebouncerContextOptions(userWorkflowId, workflowTimeout); DebouncerMessage initial = new DebouncerMessage(messageId, args, debouncePeriod); - // Use the stable class-name constant so a rename of DebouncerServiceImpl is caught at compile time. + // Use the stable class-name constant so a rename of DebouncerServiceImpl is caught at compile + // time. var enqueueOpts = new DBOSClient.EnqueueOptions( Constants.DEBOUNCER_WORKFLOW_NAME, @@ -203,8 +274,7 @@ private DebouncerClient( } catch (DBOSQueueDuplicatedException dup) { // A debouncer for this key is already running — forward the latest args to it. String existingDebouncerId = - client.findWorkflowIdByDeduplicationId( - Constants.DBOS_INTERNAL_QUEUE, deduplicationId); + client.findWorkflowIdByDeduplicationId(Constants.DBOS_INTERNAL_QUEUE, deduplicationId); if (existingDebouncerId == null) { logger.debug( "Debouncer for dedupId {} not found after conflict; retrying", deduplicationId); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index bfecde40c..51df88795 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -9,7 +9,6 @@ import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.ThrowingRunnable; import dev.dbos.transact.execution.ThrowingSupplier; -import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.internal.DebouncerContextOptions; import dev.dbos.transact.workflow.internal.DebouncerMessage; import dev.dbos.transact.workflow.internal.DebouncerOptions; @@ -110,7 +109,15 @@ private Debouncer( if (queueName != null && queueName.isEmpty()) { throw new IllegalArgumentException("queueName must not be empty"); } - return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>( + dbos, + executor, + debouncerWorkflow, + queueName, + debounceTimeout, + appVersion, + priority, + deduplicationId); } /** See {@link #withQueue(String)}. */ @@ -124,22 +131,54 @@ private Debouncer( * arriving. */ public @NonNull Debouncer withDebounceTimeout(@Nullable Duration debounceTimeout) { - return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>( + dbos, + executor, + debouncerWorkflow, + queueName, + debounceTimeout, + appVersion, + priority, + deduplicationId); } /** Target a specific application version for the user workflow. */ public @NonNull Debouncer withAppVersion(@Nullable String appVersion) { - return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>( + dbos, + executor, + debouncerWorkflow, + queueName, + debounceTimeout, + appVersion, + priority, + deduplicationId); } /** Set the priority for the user workflow (only applies when a queue is configured). */ public @NonNull Debouncer withPriority(@Nullable Integer priority) { - return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>( + dbos, + executor, + debouncerWorkflow, + queueName, + debounceTimeout, + appVersion, + priority, + deduplicationId); } /** Set a deduplication ID to be forwarded to the user workflow. */ public @NonNull Debouncer withDeduplicationId(@Nullable String deduplicationId) { - return new Debouncer<>(dbos, executor, debouncerWorkflow, queueName, debounceTimeout, appVersion, priority, deduplicationId); + return new Debouncer<>( + dbos, + executor, + debouncerWorkflow, + queueName, + debounceTimeout, + appVersion, + priority, + deduplicationId); } /** @@ -245,17 +284,20 @@ private WorkflowHandle debounceInternal( // deterministic. Mirrors Python's call_function_as_step("DBOS.get_deduplicated_workflow"). String existingDebouncerId = (DBOS.inWorkflow() && !DBOS.inStep()) - ? executor.runStep(() -> lookupExistingDebouncerId(debouncerDeduplicationId), new StepOptions("lookupDebouncer"), null) + ? executor.runStep( + () -> lookupExistingDebouncerId(debouncerDeduplicationId), + new StepOptions("lookupDebouncer"), + null) : lookupExistingDebouncerId(debouncerDeduplicationId); if (existingDebouncerId == null) { // The existing debouncer finished between the enqueue attempt and now. Retry from // scratch — the next enqueue should succeed. logger.debug( - "Debouncer for dedupId {} not found after conflict; retrying", debouncerDeduplicationId); + "Debouncer for dedupId {} not found after conflict; retrying", + debouncerDeduplicationId); continue; } - DebouncerMessage msg = - new DebouncerMessage(messageId, invocation.args(), debouncePeriod); + DebouncerMessage msg = new DebouncerMessage(messageId, invocation.args(), debouncePeriod); // messageId is the idempotency key — exactly-once delivery, no tracking flag needed. dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC, messageId); diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java index ce087dd90..f0fc3339d 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerContextOptions.java @@ -10,11 +10,10 @@ * service workflow. Contains only values that are inherently call-specific: the pre-assigned user * workflow ID and the caller's active timeout (if any). * - *

Enqueue-time options (appVersion, priority, deduplicationId) are carried in - * {@link DebouncerOptions} instead, where they are set explicitly via the Debouncer builder. + *

Enqueue-time options (appVersion, priority, deduplicationId) are carried in {@link + * DebouncerOptions} instead, where they are set explicitly via the Debouncer builder. * *

Not part of the public API. */ public record DebouncerContextOptions( - @NonNull String userWorkflowId, - @Nullable Duration workflowTimeout) {} + @NonNull String userWorkflowId, @Nullable Duration workflowTimeout) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java index 485b3dbcc..dcc032455 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java @@ -57,8 +57,7 @@ public void debouncerWorkflow( if (remaining.compareTo(Duration.ZERO) <= 0) { break; } - Duration waitDuration = - remaining.compareTo(debouncePeriod) < 0 ? remaining : debouncePeriod; + Duration waitDuration = remaining.compareTo(debouncePeriod) < 0 ? remaining : debouncePeriod; Optional msg = dbos.recv(Constants.DEBOUNCER_TOPIC, waitDuration); if (msg.isEmpty()) { diff --git a/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java b/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java index c7290039a..5cb69001d 100644 --- a/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java +++ b/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java @@ -9,7 +9,6 @@ import dev.dbos.transact.utils.PgContainer; import dev.dbos.transact.workflow.Queue; import dev.dbos.transact.workflow.Workflow; -import dev.dbos.transact.workflow.WorkflowStatus; import dev.dbos.transact.workflow.WorkflowState; import java.time.Duration; @@ -62,11 +61,13 @@ void beforeEach() { dbos.registerQueue(USER_QUEUE); dbos.launch(); - dbosClient = new DBOSClient(pgContainer.jdbcUrl(), pgContainer.username(), pgContainer.password()); + dbosClient = + new DBOSClient(pgContainer.jdbcUrl(), pgContainer.username(), pgContainer.password()); } private DebouncerClient debouncer() { - return dbosClient.debouncer("process") + return dbosClient + .debouncer("process") .withClassName(ClientTargetServiceImpl.class.getName()); } @@ -125,9 +126,8 @@ void reDebouncAfterWindowCloses() throws Exception { @Test void debouncerClientWithQueue() throws Exception { - var handle = debouncer() - .withQueue(USER_QUEUE) - .debounce("key-q", Duration.ofMillis(400), "queued"); + var handle = + debouncer().withQueue(USER_QUEUE).debounce("key-q", Duration.ofMillis(400), "queued"); assertEquals("result:queued", handle.getResult()); diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index 79ae24a84..5f0ed3534 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -6,7 +6,6 @@ import static org.junit.jupiter.api.Assertions.assertTrue; import dev.dbos.transact.DBOS; -import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.config.DBOSConfig; import dev.dbos.transact.utils.PgContainer; From a40faeb574354f04445d37fdc64a80b022dc8887 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 20:20:02 +0300 Subject: [PATCH 16/29] Make runDbosFunctionAsStep public for Debouncer - Expose DBOSExecutor.runDbosFunctionAsStep so Debouncer can use it - Prefix internal step names with "DBOS." (assignDebounceIds, lookupDebouncer) --- .../java/dev/dbos/transact/execution/DBOSExecutor.java | 2 +- .../main/java/dev/dbos/transact/workflow/Debouncer.java | 8 ++++---- 2 files changed, 5 insertions(+), 5 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 be530d512..71c960045 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -1009,7 +1009,7 @@ public T runStep( return runStepInternal(step, options, childWorkflowId); } - private T runDbosFunctionAsStep( + public T runDbosFunctionAsStep( @NonNull ThrowingSupplier step, @NonNull String stepName, @Nullable String childWorkflowId) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 51df88795..2b6c07912 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -235,12 +235,12 @@ private WorkflowHandle debounceInternal( DebounceIds ids; if (DBOS.inWorkflow() && !DBOS.inStep()) { ids = - executor.runStep( + executor.runDbosFunctionAsStep( () -> new DebounceIds( DBOSContextHolder.get().getNextWorkflowId(UUID.randomUUID().toString()), UUID.randomUUID().toString()), - new StepOptions("assignDebounceIds"), + "DBOS.assignDebounceIds", null); } else { ids = @@ -284,9 +284,9 @@ private WorkflowHandle debounceInternal( // deterministic. Mirrors Python's call_function_as_step("DBOS.get_deduplicated_workflow"). String existingDebouncerId = (DBOS.inWorkflow() && !DBOS.inStep()) - ? executor.runStep( + ? executor.runDbosFunctionAsStep( () -> lookupExistingDebouncerId(debouncerDeduplicationId), - new StepOptions("lookupDebouncer"), + "DBOS.lookupDebouncer", null) : lookupExistingDebouncerId(debouncerDeduplicationId); if (existingDebouncerId == null) { From 0f360ee2538b6d437b0e4b2c77815a5d9a9b9d4e Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 20:47:39 +0300 Subject: [PATCH 17/29] Replace DebouncerServiceImpl with InternalWorkflows --- .../java/dev/dbos/transact/Constants.java | 2 +- .../src/main/java/dev/dbos/transact/DBOS.java | 25 +++++++-------- .../dev/dbos/transact/DebouncerClient.java | 4 +-- .../transact/internal/DBOSIntegration.java | 4 +-- .../workflow/internal/DebouncerService.java | 20 ------------ ...erviceImpl.java => InternalWorkflows.java} | 32 +++++++++++++------ 6 files changed, 38 insertions(+), 49 deletions(-) delete mode 100644 transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java rename transact/src/main/java/dev/dbos/transact/workflow/internal/{DebouncerServiceImpl.java => InternalWorkflows.java} (80%) diff --git a/transact/src/main/java/dev/dbos/transact/Constants.java b/transact/src/main/java/dev/dbos/transact/Constants.java index 5bc3be130..85cc5e2a8 100644 --- a/transact/src/main/java/dev/dbos/transact/Constants.java +++ b/transact/src/main/java/dev/dbos/transact/Constants.java @@ -18,7 +18,7 @@ public class Constants { public static final String DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"; public static final String DEBOUNCER_SERVICE_CLASS_NAME = - "dev.dbos.transact.workflow.internal.DebouncerServiceImpl"; + "dev.dbos.transact.workflow.internal.InternalWorkflows"; public static final String DEBOUNCER_TOPIC = "_dbos_debouncer_topic"; // Event key published by the debouncer-workflow so callers can retrieve the pre-assigned // user workflow id without relying on Jackson deserialization of workflow inputs. diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 3255bbd4d..1164e46de 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -27,7 +27,7 @@ import dev.dbos.transact.workflow.WorkflowHandle; import dev.dbos.transact.workflow.WorkflowSchedule; import dev.dbos.transact.workflow.WorkflowStatus; -import dev.dbos.transact.workflow.internal.DebouncerServiceImpl; +import dev.dbos.transact.workflow.internal.InternalWorkflows; import java.io.IOException; import java.io.InputStream; @@ -90,20 +90,17 @@ public DBOS(@NonNull DBOSConfig config) { this.config, this.workflowRegistry, dbosExecutor::get, this::registerLifecycleListener); // Register the built-in debouncer service workflow directly (without a proxy) so callers can // use Debouncer without having to declare and wire the service themselves. - var debouncerImpl = new DebouncerServiceImpl(this); - workflowRegistry.registerInstance(null, debouncerImpl); - RegisteredWorkflow rw = null; - for (var m : DebouncerServiceImpl.class.getDeclaredMethods()) { - if (m.isAnnotationPresent(dev.dbos.transact.workflow.Workflow.class)) { - m.setAccessible(true); - rw = - integration.registerWorkflow( - m.getAnnotation(dev.dbos.transact.workflow.Workflow.class), debouncerImpl, m, null); - break; - } - } + var internalWorkflows = new InternalWorkflows(this); + workflowRegistry.registerInstance(null, internalWorkflows); this.debouncerWorkflow = - Objects.requireNonNull(rw, "DebouncerServiceImpl must have a @Workflow method"); + integration.registerWorkflow( + Constants.DEBOUNCER_WORKFLOW_NAME, + Constants.DEBOUNCER_SERVICE_CLASS_NAME, + null, + internalWorkflows, + InternalWorkflows.debouncerWorkflowMethod(), + null, + null); } /** diff --git a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java index ad00cd998..6ca985bd4 100644 --- a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java +++ b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java @@ -234,7 +234,7 @@ private DebouncerClient( if (debouncePeriod.isNegative() || debouncePeriod.isZero()) { throw new IllegalArgumentException("debouncePeriod must be a positive non-zero duration"); } - // className is required: DebouncerServiceImpl uses it to look up the registered workflow. + // className is required: the debouncer workflow uses it to look up the registered workflow. if (className == null) { throw new IllegalStateException( "className is required; call withClassName(MyServiceImpl.class.getName()) before debounce()"); @@ -258,7 +258,7 @@ private DebouncerClient( DebouncerContextOptions ctx = new DebouncerContextOptions(userWorkflowId, workflowTimeout); DebouncerMessage initial = new DebouncerMessage(messageId, args, debouncePeriod); - // Use the stable class-name constant so a rename of DebouncerServiceImpl is caught at compile + // Use the stable class-name constant so a rename of InternalWorkflows is caught at compile // time. var enqueueOpts = new DBOSClient.EnqueueOptions( diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 36227b4a3..1708eeeba 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -11,7 +11,7 @@ import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowHandle; -import dev.dbos.transact.workflow.internal.DebouncerService; +import dev.dbos.transact.workflow.internal.InternalWorkflows; import java.lang.reflect.Method; import java.util.Collection; @@ -199,7 +199,7 @@ private static boolean isInternalWorkflow(RegisteredWorkflow wf) { } private static boolean isInternalInstance(RegisteredWorkflowInstance inst) { - return inst.target() instanceof DebouncerService; + return inst.target() instanceof InternalWorkflows; } /** diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java deleted file mode 100644 index 6335f2ded..000000000 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerService.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.dbos.transact.workflow.internal; - -/** - * Internal interface for the debouncer service workflow. Registered automatically by DBOS during - * construction so users do not need to declare it. - * - *

Not part of the public API. - */ -public interface DebouncerService { - - /** - * The debouncer service workflow. Runs the recv-loop, then starts the user workflow. - * - * @param options identifies the user workflow to start and the absolute timeout - * @param ctx caller context forwarded to the user workflow - * @param initial initial debounce message from the first caller - */ - void debouncerWorkflow( - DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial); -} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java similarity index 80% rename from transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java rename to transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java index dcc032455..7c81eca52 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/DebouncerServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java @@ -3,8 +3,8 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; -import dev.dbos.transact.workflow.Workflow; +import java.lang.reflect.Method; import java.time.Duration; import java.time.Instant; import java.util.Optional; @@ -13,24 +13,36 @@ import org.slf4j.LoggerFactory; /** - * Implementation of the debouncer service workflow. Holds a reference to the {@link DBOS} instance - * to call durable primitives (recv, setEvent, runStep) and to start the user workflow when the - * debounce period elapses. + * Built-in workflows registered by DBOS itself. Currently holds the debouncer service workflow. * - *

Auto-registered by DBOS during construction. + *

Not part of the public API. */ -public class DebouncerServiceImpl implements DebouncerService { +public class InternalWorkflows { - private static final Logger logger = LoggerFactory.getLogger(DebouncerServiceImpl.class); + private static final Logger logger = LoggerFactory.getLogger(InternalWorkflows.class); private final DBOS dbos; - public DebouncerServiceImpl(DBOS dbos) { + public InternalWorkflows(DBOS dbos) { this.dbos = dbos; } - @Override - @Workflow(name = Constants.DEBOUNCER_WORKFLOW_NAME) + /** + * Returns the {@link Method} reference for {@link #debouncerWorkflow}, used by DBOS at startup to + * register the workflow without relying on reflection over {@code @Workflow} annotations. + */ + public static Method debouncerWorkflowMethod() { + try { + return InternalWorkflows.class.getDeclaredMethod( + "debouncerWorkflow", + DebouncerOptions.class, + DebouncerContextOptions.class, + DebouncerMessage.class); + } catch (NoSuchMethodException e) { + throw new IllegalStateException("debouncerWorkflow method missing", e); + } + } + public void debouncerWorkflow( DebouncerOptions options, DebouncerContextOptions ctx, DebouncerMessage initial) { From 827a50f404ad3349eaaf4564c1d4e72968feb889 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 21 May 2026 20:56:29 +0300 Subject: [PATCH 18/29] Rudiments in comments --- transact/src/main/java/dev/dbos/transact/DebouncerClient.java | 2 -- .../src/main/java/dev/dbos/transact/workflow/Debouncer.java | 3 +-- .../dev/dbos/transact/workflow/internal/InternalWorkflows.java | 1 - 3 files changed, 1 insertion(+), 5 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java index 6ca985bd4..839d8768c 100644 --- a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java +++ b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java @@ -258,8 +258,6 @@ private DebouncerClient( DebouncerContextOptions ctx = new DebouncerContextOptions(userWorkflowId, workflowTimeout); DebouncerMessage initial = new DebouncerMessage(messageId, args, debouncePeriod); - // Use the stable class-name constant so a rename of InternalWorkflows is caught at compile - // time. var enqueueOpts = new DBOSClient.EnqueueOptions( Constants.DEBOUNCER_WORKFLOW_NAME, diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 2b6c07912..ab14e0ead 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -262,7 +262,6 @@ private WorkflowHandle debounceInternal( appVersion, priority, deduplicationId); - // DBOSContextHolder.get() is guaranteed non-null inside a workflow context Duration workflowTimeout = DBOS.inWorkflow() ? DBOSContextHolder.get().getTimeout() : null; DebouncerContextOptions ctx = new DebouncerContextOptions(userWorkflowId, workflowTimeout); DebouncerMessage initial = new DebouncerMessage(messageId, invocation.args(), debouncePeriod); @@ -298,7 +297,7 @@ private WorkflowHandle debounceInternal( continue; } DebouncerMessage msg = new DebouncerMessage(messageId, invocation.args(), debouncePeriod); - // messageId is the idempotency key — exactly-once delivery, no tracking flag needed. + // messageId is the idempotency key — exactly-once delivery. dbos.send(existingDebouncerId, msg, Constants.DEBOUNCER_TOPIC, messageId); // Wait for the debouncer to acknowledge receipt. If the debouncer exited before diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java index 7c81eca52..a90764251 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java @@ -73,7 +73,6 @@ public void debouncerWorkflow( Optional msg = dbos.recv(Constants.DEBOUNCER_TOPIC, waitDuration); if (msg.isEmpty()) { - // Period elapsed with no new message — fire. break; } DebouncerMessage next = msg.get(); From aba8d05f2ccd18dda5c76fc3d6b4371dc0c549f9 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Wed, 27 May 2026 14:17:09 +0300 Subject: [PATCH 19/29] Track internal workflows in a separate registry --- .../src/main/java/dev/dbos/transact/DBOS.java | 11 ++-- .../dbos/transact/execution/DBOSExecutor.java | 20 ++++++- .../transact/internal/DBOSIntegration.java | 54 +++++++++--------- .../transact/internal/WorkflowRegistry.java | 56 ++++++++++++++++++- 4 files changed, 105 insertions(+), 36 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 1164e46de..155cb75fc 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -91,16 +91,13 @@ public DBOS(@NonNull DBOSConfig config) { // Register the built-in debouncer service workflow directly (without a proxy) so callers can // use Debouncer without having to declare and wire the service themselves. var internalWorkflows = new InternalWorkflows(this); - workflowRegistry.registerInstance(null, internalWorkflows); + workflowRegistry.registerInternalInstance(internalWorkflows); this.debouncerWorkflow = - integration.registerWorkflow( + integration.registerInternalWorkflow( Constants.DEBOUNCER_WORKFLOW_NAME, Constants.DEBOUNCER_SERVICE_CLASS_NAME, - null, internalWorkflows, - InternalWorkflows.debouncerWorkflowMethod(), - null, - null); + InternalWorkflows.debouncerWorkflowMethod()); } /** @@ -290,6 +287,8 @@ public void launch() { new HashSet<>(this.lifecycleRegistry), workflowRegistry.getWorkflowSnapshot(), workflowRegistry.getInstanceSnapshot(), + workflowRegistry.getInternalWorkflowSnapshot(), + workflowRegistry.getInternalInstanceSnapshot(), queueRegistry.getSnapshot(), alertHandler); } 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 71c960045..dcea740aa 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -117,6 +117,8 @@ public String fqName() { private Set listeners; private Map workflowMap; private Map instanceMap; + private Map internalWorkflowMap; + private Map internalInstanceMap; private Map queueMap; private ConcurrentHashMap workflowsInProgress = new ConcurrentHashMap<>(); @@ -161,6 +163,8 @@ public void start( Set listenerSet, Map workflowMap, Map instanceMap, + Map internalWorkflowMap, + Map internalInstanceMap, List queues, AlertHandler alertHandler) { @@ -169,6 +173,8 @@ public void start( this.workflowMap = workflowMap; this.instanceMap = instanceMap; + this.internalWorkflowMap = internalWorkflowMap; + this.internalInstanceMap = internalInstanceMap; this.queueMap = queues.stream().collect(Collectors.toUnmodifiableMap(Queue::name, queue -> queue)); this.listeners = listenerSet; @@ -307,6 +313,8 @@ public void close() { this.workflowMap = null; this.instanceMap = null; + this.internalWorkflowMap = null; + this.internalInstanceMap = null; logger.debug("DBOS Executor stopped"); } @@ -383,7 +391,11 @@ public Collection getRegisteredWorkflowInstances() { public Optional getRegisteredWorkflow( String workflowName, String className, @Nullable String instanceName) { var fqName = RegisteredWorkflow.fullyQualifiedName(workflowName, className, instanceName); - return Optional.ofNullable(this.workflowMap.get(fqName)); + var wf = this.workflowMap.get(fqName); + if (wf == null) { + wf = this.internalWorkflowMap.get(fqName); + } + return Optional.ofNullable(wf); } public Collection getQueues() { @@ -1335,7 +1347,9 @@ public WorkflowHandle executeWorkflowById( var wfName = RegisteredWorkflow.fullyQualifiedName( status.workflowName(), status.className(), status.instanceName()); - RegisteredWorkflow workflow = workflowMap.get(wfName); + RegisteredWorkflow workflow = + getRegisteredWorkflow(status.workflowName(), status.className(), status.instanceName()) + .orElse(null); if (workflow == null) { throw new DBOSWorkflowFunctionNotFoundException(workflowId, wfName); @@ -1400,7 +1414,7 @@ private void validateWorkflow(String workflowName, String className) { private void validateWorkflow(String workflowName, String className, String instanceName) { var fqName = RegisteredWorkflow.fullyQualifiedName(workflowName, className, instanceName); - if (!workflowMap.containsKey(fqName)) { + if (!workflowMap.containsKey(fqName) && !internalWorkflowMap.containsKey(fqName)) { throw new IllegalStateException("Workflow function %s is not registered".formatted(fqName)); } } diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 1708eeeba..ea93c42bb 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -1,6 +1,5 @@ package dev.dbos.transact.internal; -import dev.dbos.transact.Constants; import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.config.DBOSConfig; import dev.dbos.transact.database.ExternalState; @@ -11,7 +10,6 @@ import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowHandle; -import dev.dbos.transact.workflow.internal.InternalWorkflows; import java.lang.reflect.Method; import java.util.Collection; @@ -19,7 +17,6 @@ import java.util.Optional; import java.util.function.Consumer; import java.util.function.Supplier; -import java.util.stream.Collectors; import org.jspecify.annotations.NonNull; import org.jspecify.annotations.Nullable; @@ -155,6 +152,29 @@ public RegisteredWorkflow registerWorkflow( serializationStrategy); } + /** + * Register an internal DBOS system workflow. Internal workflows are tracked separately from + * user-registered workflows and are excluded from {@link #getRegisteredWorkflows()} and {@link + * #getRegisteredWorkflowInstances()}, but remain accessible to the executor for lookup, recovery, + * and dequeue. + * + * @param workflowName logical name of the internal workflow + * @param className name of the class that declares the workflow method + * @param target the singleton instance on which the method will be invoked + * @param method the workflow {@link Method} + * @throws IllegalStateException if called after DBOS is launched + */ + public RegisteredWorkflow registerInternalWorkflow( + @NonNull String workflowName, + @NonNull String className, + @NonNull Object target, + @NonNull Method method) { + if (executorSupplier.get() != null) { + throw new IllegalStateException("Cannot register workflow after DBOS is launched"); + } + return workflowRegistry.registerInternalWorkflow(workflowName, className, target, method); + } + /** * Start or enqueue a workflow by its {@link RegisteredWorkflow} registration. Intended for use by * event listeners and other infrastructure that dispatches workflows by registration rather than @@ -194,14 +214,6 @@ public Object runWorkflow( return executor("runWorkflow").runWorkflow(target, instanceName, method, args, wfTag); } - private static boolean isInternalWorkflow(RegisteredWorkflow wf) { - return Constants.DEBOUNCER_WORKFLOW_NAME.equals(wf.workflowName()); - } - - private static boolean isInternalInstance(RegisteredWorkflowInstance inst) { - return inst.target() instanceof InternalWorkflows; - } - /** * Get all user-registered workflows. Internal/system workflows registered by DBOS itself (for * example, the debouncer service workflow) are excluded. @@ -210,13 +222,9 @@ private static boolean isInternalInstance(RegisteredWorkflowInstance inst) { */ public @NonNull Collection getRegisteredWorkflows() { var executor = executorSupplier.get(); - Collection all = - executor != null - ? executor.getRegisteredWorkflows() - : workflowRegistry.getWorkflowSnapshot().values(); - return all.stream() - .filter(wf -> !isInternalWorkflow(wf)) - .collect(Collectors.toUnmodifiableList()); + return executor != null + ? executor.getRegisteredWorkflows() + : workflowRegistry.getWorkflowSnapshot().values(); } /** @@ -227,13 +235,9 @@ private static boolean isInternalInstance(RegisteredWorkflowInstance inst) { */ public @NonNull Collection getRegisteredWorkflowInstances() { var executor = executorSupplier.get(); - Collection all = - executor != null - ? executor.getRegisteredWorkflowInstances() - : workflowRegistry.getInstanceSnapshot().values(); - return all.stream() - .filter(inst -> !isInternalInstance(inst)) - .collect(Collectors.toUnmodifiableList()); + return executor != null + ? executor.getRegisteredWorkflowInstances() + : workflowRegistry.getInstanceSnapshot().values(); } /** diff --git a/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java b/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java index c96392568..4abeac76b 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java +++ b/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java @@ -34,12 +34,27 @@ public class WorkflowRegistry { new ConcurrentHashMap<>(); private final ConcurrentHashMap wfRegistry = new ConcurrentHashMap<>(); + private final ConcurrentHashMap internalWfInstRegistry = + new ConcurrentHashMap<>(); + private final ConcurrentHashMap internalWfRegistry = + new ConcurrentHashMap<>(); public void registerInstance(@Nullable String instanceName, @NonNull Object target) { + registerInstance(instanceName, target, wfInstRegistry); + } + + public void registerInternalInstance(@NonNull Object target) { + registerInstance(null, target, internalWfInstRegistry); + } + + private static void registerInstance( + @Nullable String instanceName, + @NonNull Object target, + ConcurrentHashMap registry) { var className = getWorkflowClassName(target); var fqName = RegisteredWorkflowInstance.fullyQualifiedInstName(className, instanceName); var regClass = new RegisteredWorkflowInstance(className, instanceName, target); - var previous = wfInstRegistry.putIfAbsent(fqName, regClass); + var previous = registry.putIfAbsent(fqName, regClass); if (previous != null) { throw new IllegalStateException("Workflow class already registered with name: " + fqName); } @@ -53,6 +68,35 @@ public RegisteredWorkflow registerWorkflow( @NonNull Method method, @Nullable Integer maxRecoveryAttempts, @Nullable SerializationStrategy serializationStrategy) { + return registerWorkflow( + workflowName, + className, + instanceName, + target, + method, + maxRecoveryAttempts, + serializationStrategy, + wfRegistry); + } + + public RegisteredWorkflow registerInternalWorkflow( + @NonNull String workflowName, + @NonNull String className, + @NonNull Object target, + @NonNull Method method) { + return registerWorkflow( + workflowName, className, null, target, method, null, null, internalWfRegistry); + } + + private static RegisteredWorkflow registerWorkflow( + @NonNull String workflowName, + @NonNull String className, + @Nullable String instanceName, + @NonNull Object target, + @NonNull Method method, + @Nullable Integer maxRecoveryAttempts, + @Nullable SerializationStrategy serializationStrategy, + ConcurrentHashMap registry) { var fqName = RegisteredWorkflow.fullyQualifiedName(workflowName, className, instanceName); var regWorkflow = @@ -65,7 +109,7 @@ public RegisteredWorkflow registerWorkflow( Objects.requireNonNullElse(maxRecoveryAttempts, -1), Objects.requireNonNullElse(serializationStrategy, SerializationStrategy.DEFAULT)); - var previous = wfRegistry.putIfAbsent(fqName, regWorkflow); + var previous = registry.putIfAbsent(fqName, regWorkflow); if (previous != null) { throw new IllegalStateException("Workflow already registered with name: " + fqName); } @@ -79,4 +123,12 @@ public Map getWorkflowSnapshot() { public Map getInstanceSnapshot() { return Map.copyOf(wfInstRegistry); } + + public Map getInternalWorkflowSnapshot() { + return Map.copyOf(internalWfRegistry); + } + + public Map getInternalInstanceSnapshot() { + return Map.copyOf(internalWfInstRegistry); + } } From ea07bb4130f59411ee6b87acbdd8e2c5d7a0933c Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Wed, 27 May 2026 14:34:50 +0300 Subject: [PATCH 20/29] Use portable class name for internal debouncer workflow --- transact/src/main/java/dev/dbos/transact/Constants.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/Constants.java b/transact/src/main/java/dev/dbos/transact/Constants.java index 85cc5e2a8..4862d7d86 100644 --- a/transact/src/main/java/dev/dbos/transact/Constants.java +++ b/transact/src/main/java/dev/dbos/transact/Constants.java @@ -17,8 +17,7 @@ public class Constants { public static final String DBOS_INTERNAL_QUEUE = "_dbos_internal_queue"; public static final String DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"; - public static final String DEBOUNCER_SERVICE_CLASS_NAME = - "dev.dbos.transact.workflow.internal.InternalWorkflows"; + public static final String DEBOUNCER_SERVICE_CLASS_NAME = "DBOS.InternalWorkflows"; public static final String DEBOUNCER_TOPIC = "_dbos_debouncer_topic"; // Event key published by the debouncer-workflow so callers can retrieve the pre-assigned // user workflow id without relying on Jackson deserialization of workflow inputs. From d77f70f5b460e9eb9c3371316031c6960c411b53 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 28 May 2026 14:07:47 +0300 Subject: [PATCH 21/29] Fail debounced workflow with ERROR instead of hanging when unregistered Add recordErrorForUnstartedWorkflow so awaiting handles fail fast; validate registration up front in Debouncer. --- .../transact/database/SystemDatabase.java | 8 ++++ .../transact/database/dao/WorkflowDAO.java | 22 +++++++++ .../dbos/transact/execution/DBOSExecutor.java | 46 +++++++++++++++++++ .../transact/internal/DBOSIntegration.java | 17 +++++++ .../dev/dbos/transact/workflow/Debouncer.java | 10 ++++ .../workflow/internal/InternalWorkflows.java | 39 +++++++++++----- .../transact/client/DebouncerClientTest.java | 2 +- .../dbos/transact/workflow/DebouncerTest.java | 2 +- 8 files changed, 132 insertions(+), 14 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 c4ecba529..7a5ebf5fe 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -359,6 +359,14 @@ public void recordWorkflowError(String workflowId, String error) { dbRetry(() -> WorkflowDAO.recordWorkflowError(ctx, workflowId, error)); } + /** + * Insert a workflow_status row already in the ERROR state, for a workflow that was never started. + * See {@link WorkflowDAO#recordErrorForUnstartedWorkflow}. + */ + public void recordErrorForUnstartedWorkflow(WorkflowStatusInternal initStatus, String error) { + dbRetry(() -> WorkflowDAO.recordErrorForUnstartedWorkflow(ctx, initStatus, error)); + } + public WorkflowStatus getWorkflowStatus(String workflowId) { return dbRetry(() -> WorkflowDAO.getWorkflowStatus(ctx, workflowId)); } diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java index 6fb3847a4..8f7f5314f 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java @@ -374,6 +374,28 @@ public static void recordWorkflowError(DbContext ctx, String workflowId, String } } + /** + * Insert a workflow_status row and immediately mark it ERROR, for a workflow that was never + * actually started. Used when an internal workflow that is responsible for starting a user + * workflow fails before it can do so: without a status row, any handle awaiting the user + * workflow would poll {@link #awaitWorkflowResult} forever. + * + * @param initStatus metadata for the workflow that will be recorded as failed + * @param error the error serialized as json + */ + public static void recordErrorForUnstartedWorkflow( + DbContext ctx, WorkflowStatusInternal initStatus, String error) throws SQLException { + + // No explicit transaction: the calling debouncer workflow is itself durable, so a crash + // between these two statements is replayed and retried. ON CONFLICT makes the insert + // idempotent and the outcome update is safe to repeat. + try (var conn = ctx.getConnection()) { + insertWorkflowStatus(conn, ctx.schema(), initStatus, UUID.randomUUID().toString(), false); + updateWorkflowOutcome( + conn, ctx.schema(), initStatus.workflowId(), WorkflowState.ERROR, null, error); + } + } + public static String getWorkflowSerialization(DbContext ctx, String workflowId) throws SQLException { var sql = 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 35812051c..58c851731 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -1882,4 +1882,50 @@ private void persistWorkflowError(String workflowId, Throwable error, String ser var serialized = SerializationUtil.serializeError(error, serialization, this.serializer); systemDatabase.recordWorkflowError(workflowId, serialized.serializedValue()); } + + /** + * Record an ERROR result for a workflow that was never started, so that handles awaiting it fail + * fast instead of polling forever. Used by internal workflows (e.g. the debouncer) that take + * responsibility for starting a user workflow and must surface their own failures to the caller's + * handle when they cannot. + */ + public void recordErrorForUnstartedWorkflow( + String workflowId, + String workflowName, + String className, + @Nullable String instanceName, + @Nullable Object[] args, + Throwable error) { + String serialization = this.serializer.name(); + var serializedArgs = + SerializationUtil.serializeArgs( + Objects.requireNonNullElseGet(args, () -> new Object[0]), + null, + serialization, + this.serializer); + var serializedError = SerializationUtil.serializeError(error, serialization, this.serializer); + var initStatus = + new WorkflowStatusInternal( + workflowId, + workflowName, + className, + instanceName, + null, + null, + null, + null, + null, + null, + null, + null, + serializedArgs.serializedValue(), + executorId(), + appVersion(), + appId(), + null, + null, + null, + serializedArgs.serialization()); + systemDatabase.recordErrorForUnstartedWorkflow(initStatus, serializedError.serializedValue()); + } } diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index ea93c42bb..5ef9bb054 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -195,6 +195,23 @@ public RegisteredWorkflow registerInternalWorkflow( return executor("startRegisteredWorkflow").startRegisteredWorkflow(regWorkflow, args, options); } + /** + * Record a terminal ERROR for a workflow that was never started, so handles awaiting it fail + * fast instead of polling forever. Used by the built-in debouncer workflow when it cannot start + * the user workflow it is responsible for. + */ + public void recordErrorForUnstartedWorkflow( + String workflowId, + String workflowName, + String className, + @Nullable String instanceName, + @Nullable Object[] args, + Throwable error) { + executor("recordErrorForUnstartedWorkflow") + .recordErrorForUnstartedWorkflow( + workflowId, workflowName, className, instanceName, args, error); + } + /** * Execute a workflow method via its reflective {@link Method} handle. Intended for use by AOP * interceptors that capture workflow invocations at the proxy boundary. diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index ab14e0ead..caf36ea45 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -5,6 +5,7 @@ import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.context.DBOSContextHolder; import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; +import dev.dbos.transact.exceptions.DBOSWorkflowFunctionNotFoundException; import dev.dbos.transact.execution.DBOSExecutor; import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.ThrowingRunnable; @@ -250,6 +251,15 @@ private WorkflowHandle debounceInternal( } String userWorkflowId = ids.userWorkflowId(); String messageId = ids.messageId(); + + // Fail fast for the common programmer error of debouncing an unregistered workflow. Without + // this the call would still succeed here and only fail later inside the debouncer workflow. + if (executor + .getRegisteredWorkflow( + invocation.workflowName(), invocation.className(), invocation.instanceName()) + .isEmpty()) { + throw new DBOSWorkflowFunctionNotFoundException(userWorkflowId, invocation.workflowName()); + } String debouncerDeduplicationId = invocation.workflowName() + "-" + debounceKey; DebouncerOptions options = diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java index a90764251..ea8503690 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java @@ -3,6 +3,8 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.exceptions.DBOSWorkflowFunctionNotFoundException; +import dev.dbos.transact.execution.RegisteredWorkflow; import java.lang.reflect.Method; import java.time.Duration; @@ -82,20 +84,33 @@ public void debouncerWorkflow( dbos.setEvent(next.messageId(), next.messageId()); } - var workflow = + Optional optWorkflow = dbos.integration() .getRegisteredWorkflow( - options.workflowName(), options.className(), options.instanceName()) - .orElseThrow( - () -> - new IllegalStateException( - "Debouncer cannot find registered user workflow: " - + options.workflowName() - + " / " - + options.className() - + (options.instanceName() == null - ? "" - : " / " + options.instanceName()))); + options.workflowName(), options.className(), options.instanceName()); + if (optWorkflow.isEmpty()) { + // The user workflow is not registered in this process (e.g. it was renamed/removed, or we + // are recovering on a build that no longer declares it). We can never start it, so record + // a terminal ERROR for the pre-assigned user workflow id. Otherwise any handle returned to + // the caller would poll getResult() forever, since the status row would never appear. + var notFound = + new DBOSWorkflowFunctionNotFoundException(ctx.userWorkflowId(), options.workflowName()); + logger.error( + "Debouncer cannot find registered user workflow {} (id={}); recording ERROR", + options.workflowName(), + ctx.userWorkflowId(), + notFound); + dbos.integration() + .recordErrorForUnstartedWorkflow( + ctx.userWorkflowId(), + options.workflowName(), + options.className(), + options.instanceName(), + latestArgs, + notFound); + return; + } + var workflow = optWorkflow.get(); // priority and deduplicationId are only valid for queued workflows; the executor // throws IllegalArgumentException if they are set without a queue name. diff --git a/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java b/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java index 5cb69001d..0fa17cb79 100644 --- a/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java +++ b/transact/src/test/java/dev/dbos/transact/client/DebouncerClientTest.java @@ -108,7 +108,7 @@ void differentKeysFireIndependently() throws Exception { } @Test - void reDebouncAfterWindowCloses() throws Exception { + void reDebounceAfterWindowCloses() throws Exception { var d = debouncer(); var h1 = d.debounce("key-r", Duration.ofMillis(300), "first"); diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index 5f0ed3534..b1e31cb27 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -337,7 +337,7 @@ public void explicitPriorityForwardedToUserWorkflow() throws Exception { // Regression test for: deduplication_id is cleared to NULL on completion, so the UNIQUE // constraint no longer blocks a new enqueue with the same key. @Test - public void reDebouncAfterWindowCloses() throws Exception { + public void reDebounceAfterWindowCloses() throws Exception { DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); dbos.launch(); From 40215c382ac91d525b1f4909e66dbd0d31ca029d Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 28 May 2026 14:15:56 +0300 Subject: [PATCH 22/29] Use runDbosFunctionAsStep directly in Debouncer instead of manual inWorkflow/inStep checks --- .../dev/dbos/transact/workflow/Debouncer.java | 37 +++++++------------ 1 file changed, 14 insertions(+), 23 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index caf36ea45..653d055af 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -232,23 +232,16 @@ private WorkflowHandle debounceInternal( DBOSExecutor.Invocation invocation = executor.captureInvocation(wfLambda); - // Inside a workflow, ID generation is wrapped in a step so replay is deterministic. - DebounceIds ids; - if (DBOS.inWorkflow() && !DBOS.inStep()) { - ids = - executor.runDbosFunctionAsStep( - () -> - new DebounceIds( - DBOSContextHolder.get().getNextWorkflowId(UUID.randomUUID().toString()), - UUID.randomUUID().toString()), - "DBOS.assignDebounceIds", - null); - } else { - ids = - new DebounceIds( - DBOSContextHolder.get().getNextWorkflowId(UUID.randomUUID().toString()), - UUID.randomUUID().toString()); - } + // Inside a workflow, ID generation is wrapped in a step so replay is deterministic; + // runDbosFunctionAsStep runs the lambda directly when not in a workflow. + DebounceIds ids = + executor.runDbosFunctionAsStep( + () -> + new DebounceIds( + DBOSContextHolder.get().getNextWorkflowId(UUID.randomUUID().toString()), + UUID.randomUUID().toString()), + "DBOS.assignDebounceIds", + null); String userWorkflowId = ids.userWorkflowId(); String messageId = ids.messageId(); @@ -292,12 +285,10 @@ private WorkflowHandle debounceInternal( // replay returns the same debouncer id and the subsequent send/getEvent steps stay // deterministic. Mirrors Python's call_function_as_step("DBOS.get_deduplicated_workflow"). String existingDebouncerId = - (DBOS.inWorkflow() && !DBOS.inStep()) - ? executor.runDbosFunctionAsStep( - () -> lookupExistingDebouncerId(debouncerDeduplicationId), - "DBOS.lookupDebouncer", - null) - : lookupExistingDebouncerId(debouncerDeduplicationId); + executor.runDbosFunctionAsStep( + () -> lookupExistingDebouncerId(debouncerDeduplicationId), + "DBOS.lookupDebouncer", + null); if (existingDebouncerId == null) { // The existing debouncer finished between the enqueue attempt and now. Retry from // scratch — the next enqueue should succeed. From a25a54aaaf830652fe19999eec798db8c1ecc284 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 28 May 2026 14:26:22 +0300 Subject: [PATCH 23/29] Rename internal debouncer workflow to debouncerWorkflow, drop redundant dbos prefix --- transact/src/main/java/dev/dbos/transact/Constants.java | 4 ++-- transact/src/main/java/dev/dbos/transact/DBOS.java | 2 +- transact/src/main/java/dev/dbos/transact/DebouncerClient.java | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/Constants.java b/transact/src/main/java/dev/dbos/transact/Constants.java index 4862d7d86..6750f5338 100644 --- a/transact/src/main/java/dev/dbos/transact/Constants.java +++ b/transact/src/main/java/dev/dbos/transact/Constants.java @@ -16,8 +16,8 @@ public class Constants { public static final String DBOS_INTERNAL_QUEUE = "_dbos_internal_queue"; - public static final String DEBOUNCER_WORKFLOW_NAME = "_dbos_debouncer_workflow"; - public static final String DEBOUNCER_SERVICE_CLASS_NAME = "DBOS.InternalWorkflows"; + public static final String DEBOUNCER_WORKFLOW_NAME = "debouncerWorkflow"; + public static final String DEBOUNCER_CLASS_NAME = "DBOS.InternalWorkflows"; public static final String DEBOUNCER_TOPIC = "_dbos_debouncer_topic"; // Event key published by the debouncer-workflow so callers can retrieve the pre-assigned // user workflow id without relying on Jackson deserialization of workflow inputs. diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index dc3f6fea7..7aa96aab4 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -97,7 +97,7 @@ public DBOS(@NonNull DBOSConfig config) { this.debouncerWorkflow = integration.registerInternalWorkflow( Constants.DEBOUNCER_WORKFLOW_NAME, - Constants.DEBOUNCER_SERVICE_CLASS_NAME, + Constants.DEBOUNCER_CLASS_NAME, internalWorkflows, InternalWorkflows.debouncerWorkflowMethod()); } diff --git a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java index 839d8768c..e76d0bfbb 100644 --- a/transact/src/main/java/dev/dbos/transact/DebouncerClient.java +++ b/transact/src/main/java/dev/dbos/transact/DebouncerClient.java @@ -261,7 +261,7 @@ private DebouncerClient( var enqueueOpts = new DBOSClient.EnqueueOptions( Constants.DEBOUNCER_WORKFLOW_NAME, - Constants.DEBOUNCER_SERVICE_CLASS_NAME, + Constants.DEBOUNCER_CLASS_NAME, Constants.DBOS_INTERNAL_QUEUE) .withDeduplicationId(deduplicationId); From fa57962136a9258c8ff5d1c0db8772163031f079 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 28 May 2026 14:44:04 +0300 Subject: [PATCH 24/29] Add recovery test: replaying debouncer workflow must not restart user workflow --- .../dbos/transact/workflow/DebouncerTest.java | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index b1e31cb27..dd055d037 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -5,11 +5,17 @@ import static org.junit.jupiter.api.Assertions.assertNotNull; 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.config.DBOSConfig; import dev.dbos.transact.utils.PgContainer; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; import java.time.Duration; +import java.time.Instant; import java.util.List; import java.util.concurrent.ConcurrentLinkedQueue; import java.util.concurrent.CountDownLatch; @@ -359,4 +365,48 @@ public void reDebounceAfterWindowCloses() throws Exception { // Each window produces an independent user workflow. assertNotEquals(h1.workflowId(), h2.workflowId()); } + + // Recovering/replaying the internal debouncer workflow must be idempotent: it reuses the + // pre-assigned user workflow id and must not start a second user workflow execution. + @Test + public void recoveryDoesNotRestartUserWorkflow() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + dbos.launch(); + + var handle = + dbos.debouncer().debounce("rec-key", Duration.ofMillis(300), () -> svc.process("v1")); + String userWorkflowId = handle.workflowId(); + assertEquals("result:v1", handle.getResult()); + assertEquals(1, serviceImpl.callCount()); + + // Simulate a crash where the debouncer ran but did not durably record completion: flip only + // the debouncer workflow back to PENDING (the user workflow stays SUCCESS) and recover it. + var executor = DBOSTestAccess.getDbosExecutor(dbos); + markDebouncerPending(); + + var recovered = executor.recoverPendingWorkflows(List.of(executor.executorId())); + assertEquals(1, recovered.size()); + for (var h : recovered) { + h.getResult(); + } + + // Replay reused the same user workflow id and did not run the user workflow again. + assertEquals(1, serviceImpl.callCount()); + assertEquals(List.of("v1"), serviceImpl.callArgs()); + WorkflowHandle userHandle = dbos.retrieveWorkflow(userWorkflowId); + assertEquals("result:v1", userHandle.getResult()); + assertEquals(WorkflowState.SUCCESS, userHandle.getStatus().status()); + } + + private void markDebouncerPending() throws SQLException { + var sql = + "UPDATE dbos.workflow_status SET status = ?, queue_name = NULL, updated_at = ? WHERE name = ?"; + try (Connection conn = pgContainer.dataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, WorkflowState.PENDING.name()); + stmt.setLong(2, Instant.now().toEpochMilli()); + stmt.setString(3, Constants.DEBOUNCER_WORKFLOW_NAME); + stmt.executeUpdate(); + } + } } From dee7b874f66e36d8d046570bf32eea373d542b56 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 28 May 2026 15:02:01 +0300 Subject: [PATCH 25/29] Test withDeduplicationId forwarding and harden debouncer recovery test --- .../dbos/transact/workflow/DebouncerTest.java | 100 ++++++++++++++++-- 1 file changed, 91 insertions(+), 9 deletions(-) diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index dd055d037..c2474fcc2 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -46,12 +46,23 @@ public interface DebouncedService { public static class DebouncedServiceImpl implements DebouncedService { private final AtomicInteger callCount = new AtomicInteger(); private final ConcurrentLinkedQueue callArgs = new ConcurrentLinkedQueue<>(); + // When set, the workflow blocks here while running so tests can inspect its in-flight status. + volatile CountDownLatch gate; @Override @Workflow public String process(String input) { callCount.incrementAndGet(); callArgs.add(input); + if (gate != null) { + try { + // Ceiling only; the test counts the gate down as soon as it has observed the status. + // Must exceed the observation window so the workflow stays in-flight until then. + gate.await(60, TimeUnit.SECONDS); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } return "result:" + input; } @@ -381,8 +392,10 @@ public void recoveryDoesNotRestartUserWorkflow() throws Exception { // Simulate a crash where the debouncer ran but did not durably record completion: flip only // the debouncer workflow back to PENDING (the user workflow stays SUCCESS) and recover it. + // The debouncer finishes asynchronously after starting the user workflow, so wait until it is + // SUCCESS before flipping — otherwise the flip would race its own completion. var executor = DBOSTestAccess.getDbosExecutor(dbos); - markDebouncerPending(); + awaitDebouncerFlippedToPending(Duration.ofSeconds(30)); var recovered = executor.recoverPendingWorkflows(List.of(executor.executorId())); assertEquals(1, recovered.size()); @@ -390,7 +403,10 @@ public void recoveryDoesNotRestartUserWorkflow() throws Exception { h.getResult(); } - // Replay reused the same user workflow id and did not run the user workflow again. + // Replay reused the same user workflow id and did not run the user workflow again. The count + // check is independent of timing: a second user workflow would create a row at enqueue/start + // time, before it could execute, so it would be caught even if its body had not run yet. + assertEquals(1, countWorkflowsByName("process")); assertEquals(1, serviceImpl.callCount()); assertEquals(List.of("v1"), serviceImpl.callArgs()); WorkflowHandle userHandle = dbos.retrieveWorkflow(userWorkflowId); @@ -398,15 +414,81 @@ public void recoveryDoesNotRestartUserWorkflow() throws Exception { assertEquals(WorkflowState.SUCCESS, userHandle.getStatus().status()); } - private void markDebouncerPending() throws SQLException { - var sql = - "UPDATE dbos.workflow_status SET status = ?, queue_name = NULL, updated_at = ? WHERE name = ?"; + private int countWorkflowsByName(String name) throws SQLException { + var sql = "SELECT count(*) FROM dbos.workflow_status WHERE name = ?"; try (Connection conn = pgContainer.dataSource().getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, WorkflowState.PENDING.name()); - stmt.setLong(2, Instant.now().toEpochMilli()); - stmt.setString(3, Constants.DEBOUNCER_WORKFLOW_NAME); - stmt.executeUpdate(); + stmt.setString(1, name); + try (var rs = stmt.executeQuery()) { + rs.next(); + return rs.getInt(1); + } + } + } + + // withDeduplicationId must forward the id to the queued user workflow. + @Test + public void deduplicationIdForwardedToQueuedUserWorkflow() throws Exception { + DebouncedService svc = dbos.registerProxy(DebouncedService.class, serviceImpl); + Queue userQueue = new Queue("dedup-user-queue"); + dbos.registerQueue(userQueue); + serviceImpl.gate = new CountDownLatch(1); + dbos.launch(); + + String dedupId = "user-dedup-1"; + var handle = + dbos.debouncer() + .withQueue(userQueue) + .withDeduplicationId(dedupId) + .debounce("dd-key", Duration.ofMillis(300), () -> svc.process("v1")); + + // The user workflow blocks on the gate while running, so its deduplication_id is still set + // (it is cleared only on completion). Wait for it to appear, then assert it was forwarded. + String observed = awaitDeduplicationId(handle, Duration.ofSeconds(30)); + assertEquals(dedupId, observed); + + serviceImpl.gate.countDown(); + assertEquals("result:v1", handle.getResult()); + assertEquals(1, serviceImpl.callCount()); + } + + private String awaitDeduplicationId(WorkflowHandle handle, Duration timeout) + throws InterruptedException { + long deadline = System.currentTimeMillis() + timeout.toMillis(); + while (System.currentTimeMillis() < deadline) { + try { + var status = handle.getStatus(); + if (status != null && status.deduplicationId() != null) { + return status.deduplicationId(); + } + } catch (RuntimeException ignored) { + // status row not present yet + } + Thread.sleep(50); + } + throw new AssertionError("user workflow deduplicationId not observed within timeout"); + } + + // Flip the (completed) debouncer workflow back to PENDING, retrying until it has reached SUCCESS + // so the result is deterministic regardless of how the debouncer's async completion interleaves. + private void awaitDebouncerFlippedToPending(Duration timeout) throws Exception { + var sql = + "UPDATE dbos.workflow_status SET status = ?, queue_name = NULL, updated_at = ?" + + " WHERE name = ? AND status = ?"; + long deadline = System.currentTimeMillis() + timeout.toMillis(); + while (System.currentTimeMillis() < deadline) { + try (Connection conn = pgContainer.dataSource().getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, WorkflowState.PENDING.name()); + stmt.setLong(2, Instant.now().toEpochMilli()); + stmt.setString(3, Constants.DEBOUNCER_WORKFLOW_NAME); + stmt.setString(4, WorkflowState.SUCCESS.name()); + if (stmt.executeUpdate() == 1) { + return; + } + } + Thread.sleep(50); } + throw new AssertionError("debouncer workflow did not reach SUCCESS within timeout"); } } From f0177d727471822697b257173f6e660b17fd8503 Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 28 May 2026 09:11:40 -0700 Subject: [PATCH 26/29] spotless --- .../java/dev/dbos/transact/database/dao/WorkflowDAO.java | 4 ++-- .../java/dev/dbos/transact/internal/DBOSIntegration.java | 6 +++--- .../test/java/dev/dbos/transact/workflow/DebouncerTest.java | 3 ++- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java index 8f7f5314f..fba3ff616 100644 --- a/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/dao/WorkflowDAO.java @@ -377,8 +377,8 @@ public static void recordWorkflowError(DbContext ctx, String workflowId, String /** * Insert a workflow_status row and immediately mark it ERROR, for a workflow that was never * actually started. Used when an internal workflow that is responsible for starting a user - * workflow fails before it can do so: without a status row, any handle awaiting the user - * workflow would poll {@link #awaitWorkflowResult} forever. + * workflow fails before it can do so: without a status row, any handle awaiting the user workflow + * would poll {@link #awaitWorkflowResult} forever. * * @param initStatus metadata for the workflow that will be recorded as failed * @param error the error serialized as json diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 5ef9bb054..0d162cd21 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -196,9 +196,9 @@ public RegisteredWorkflow registerInternalWorkflow( } /** - * Record a terminal ERROR for a workflow that was never started, so handles awaiting it fail - * fast instead of polling forever. Used by the built-in debouncer workflow when it cannot start - * the user workflow it is responsible for. + * Record a terminal ERROR for a workflow that was never started, so handles awaiting it fail fast + * instead of polling forever. Used by the built-in debouncer workflow when it cannot start the + * user workflow it is responsible for. */ public void recordErrorForUnstartedWorkflow( String workflowId, diff --git a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java index c2474fcc2..651182b49 100644 --- a/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java +++ b/transact/src/test/java/dev/dbos/transact/workflow/DebouncerTest.java @@ -385,7 +385,8 @@ public void recoveryDoesNotRestartUserWorkflow() throws Exception { dbos.launch(); var handle = - dbos.debouncer().debounce("rec-key", Duration.ofMillis(300), () -> svc.process("v1")); + dbos.debouncer() + .debounce("rec-key", Duration.ofMillis(300), () -> svc.process("v1")); String userWorkflowId = handle.workflowId(); assertEquals("result:v1", handle.getResult()); assertEquals(1, serviceImpl.callCount()); From 0ba8506f9197a4866811088c9be7f1da952dd9f1 Mon Sep 17 00:00:00 2001 From: Eugene Smith Date: Thu, 28 May 2026 20:27:19 +0300 Subject: [PATCH 27/29] Call executor/registry directly from internal workflows, drop redundant integration wrappers - DBOS constructor registers internal workflow via workflowRegistry directly - InternalWorkflows uses Supplier for executor-level calls - Remove unused Debouncer fail-fast check (error surfaced in debouncerWorkflow) --- .../src/main/java/dev/dbos/transact/DBOS.java | 4 +- .../transact/internal/DBOSIntegration.java | 40 ------------------- .../dev/dbos/transact/workflow/Debouncer.java | 9 ----- .../workflow/internal/InternalWorkflows.java | 29 ++++++++------ 4 files changed, 18 insertions(+), 64 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 7aa96aab4..a8608e99d 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -92,10 +92,10 @@ public DBOS(@NonNull DBOSConfig config) { this.config, this.workflowRegistry, dbosExecutor::get, this::registerLifecycleListener); // Register the built-in debouncer service workflow directly (without a proxy) so callers can // use Debouncer without having to declare and wire the service themselves. - var internalWorkflows = new InternalWorkflows(this); + var internalWorkflows = new InternalWorkflows(this, dbosExecutor::get); workflowRegistry.registerInternalInstance(internalWorkflows); this.debouncerWorkflow = - integration.registerInternalWorkflow( + workflowRegistry.registerInternalWorkflow( Constants.DEBOUNCER_WORKFLOW_NAME, Constants.DEBOUNCER_CLASS_NAME, internalWorkflows, diff --git a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java index 0d162cd21..7b7e9c763 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java +++ b/transact/src/main/java/dev/dbos/transact/internal/DBOSIntegration.java @@ -152,29 +152,6 @@ public RegisteredWorkflow registerWorkflow( serializationStrategy); } - /** - * Register an internal DBOS system workflow. Internal workflows are tracked separately from - * user-registered workflows and are excluded from {@link #getRegisteredWorkflows()} and {@link - * #getRegisteredWorkflowInstances()}, but remain accessible to the executor for lookup, recovery, - * and dequeue. - * - * @param workflowName logical name of the internal workflow - * @param className name of the class that declares the workflow method - * @param target the singleton instance on which the method will be invoked - * @param method the workflow {@link Method} - * @throws IllegalStateException if called after DBOS is launched - */ - public RegisteredWorkflow registerInternalWorkflow( - @NonNull String workflowName, - @NonNull String className, - @NonNull Object target, - @NonNull Method method) { - if (executorSupplier.get() != null) { - throw new IllegalStateException("Cannot register workflow after DBOS is launched"); - } - return workflowRegistry.registerInternalWorkflow(workflowName, className, target, method); - } - /** * Start or enqueue a workflow by its {@link RegisteredWorkflow} registration. Intended for use by * event listeners and other infrastructure that dispatches workflows by registration rather than @@ -195,23 +172,6 @@ public RegisteredWorkflow registerInternalWorkflow( return executor("startRegisteredWorkflow").startRegisteredWorkflow(regWorkflow, args, options); } - /** - * Record a terminal ERROR for a workflow that was never started, so handles awaiting it fail fast - * instead of polling forever. Used by the built-in debouncer workflow when it cannot start the - * user workflow it is responsible for. - */ - public void recordErrorForUnstartedWorkflow( - String workflowId, - String workflowName, - String className, - @Nullable String instanceName, - @Nullable Object[] args, - Throwable error) { - executor("recordErrorForUnstartedWorkflow") - .recordErrorForUnstartedWorkflow( - workflowId, workflowName, className, instanceName, args, error); - } - /** * Execute a workflow method via its reflective {@link Method} handle. Intended for use by AOP * interceptors that capture workflow invocations at the proxy boundary. diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java index 653d055af..c568f805a 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Debouncer.java @@ -5,7 +5,6 @@ import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.context.DBOSContextHolder; import dev.dbos.transact.exceptions.DBOSQueueDuplicatedException; -import dev.dbos.transact.exceptions.DBOSWorkflowFunctionNotFoundException; import dev.dbos.transact.execution.DBOSExecutor; import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.ThrowingRunnable; @@ -245,14 +244,6 @@ private WorkflowHandle debounceInternal( String userWorkflowId = ids.userWorkflowId(); String messageId = ids.messageId(); - // Fail fast for the common programmer error of debouncing an unregistered workflow. Without - // this the call would still succeed here and only fail later inside the debouncer workflow. - if (executor - .getRegisteredWorkflow( - invocation.workflowName(), invocation.className(), invocation.instanceName()) - .isEmpty()) { - throw new DBOSWorkflowFunctionNotFoundException(userWorkflowId, invocation.workflowName()); - } String debouncerDeduplicationId = invocation.workflowName() + "-" + debounceKey; DebouncerOptions options = diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java index ea8503690..3c7c7c2b7 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java @@ -4,12 +4,14 @@ import dev.dbos.transact.DBOS; import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.exceptions.DBOSWorkflowFunctionNotFoundException; +import dev.dbos.transact.execution.DBOSExecutor; import dev.dbos.transact.execution.RegisteredWorkflow; import java.lang.reflect.Method; import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.function.Supplier; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -24,9 +26,11 @@ public class InternalWorkflows { private static final Logger logger = LoggerFactory.getLogger(InternalWorkflows.class); private final DBOS dbos; + private final Supplier executorSupplier; - public InternalWorkflows(DBOS dbos) { + public InternalWorkflows(DBOS dbos, Supplier executorSupplier) { this.dbos = dbos; + this.executorSupplier = executorSupplier; } /** @@ -84,10 +88,10 @@ public void debouncerWorkflow( dbos.setEvent(next.messageId(), next.messageId()); } + DBOSExecutor executor = executorSupplier.get(); Optional optWorkflow = - dbos.integration() - .getRegisteredWorkflow( - options.workflowName(), options.className(), options.instanceName()); + executor.getRegisteredWorkflow( + options.workflowName(), options.className(), options.instanceName()); if (optWorkflow.isEmpty()) { // The user workflow is not registered in this process (e.g. it was renamed/removed, or we // are recovering on a build that no longer declares it). We can never start it, so record @@ -100,14 +104,13 @@ public void debouncerWorkflow( options.workflowName(), ctx.userWorkflowId(), notFound); - dbos.integration() - .recordErrorForUnstartedWorkflow( - ctx.userWorkflowId(), - options.workflowName(), - options.className(), - options.instanceName(), - latestArgs, - notFound); + executor.recordErrorForUnstartedWorkflow( + ctx.userWorkflowId(), + options.workflowName(), + options.className(), + options.instanceName(), + latestArgs, + notFound); return; } var workflow = optWorkflow.get(); @@ -130,6 +133,6 @@ public void debouncerWorkflow( "Debouncer starting user workflow {} (id={})", options.workflowName(), ctx.userWorkflowId()); - dbos.integration().startRegisteredWorkflow(workflow, latestArgs, startOpts); + executor.startRegisteredWorkflow(workflow, latestArgs, startOpts); } } From 25d668c300ef91dca2730deb907a856c9dd87b9a Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 28 May 2026 10:57:52 -0700 Subject: [PATCH 28/29] minor change to check executorSupplier.get response --- .../dbos/transact/workflow/internal/InternalWorkflows.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java index 3c7c7c2b7..848bc59f7 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/InternalWorkflows.java @@ -69,6 +69,10 @@ public void debouncerWorkflow( Object[] latestArgs = initial.args(); Duration debouncePeriod = initial.debouncePeriod(); + DBOSExecutor executor = executorSupplier.get(); + if (executor == null) { + throw new IllegalStateException("DBOS has not been launched. debounceWorkflow cannot run."); + } while (true) { long nowEpochMs = dbos.runStep(() -> Instant.now().toEpochMilli(), "DBOS.debouncerNow"); Duration remaining = Duration.ofMillis(deadlineEpochMs - nowEpochMs); @@ -88,7 +92,6 @@ public void debouncerWorkflow( dbos.setEvent(next.messageId(), next.messageId()); } - DBOSExecutor executor = executorSupplier.get(); Optional optWorkflow = executor.getRegisteredWorkflow( options.workflowName(), options.className(), options.instanceName()); From ee79c2fc660b12757ae6a8c29894355e98bea61a Mon Sep 17 00:00:00 2001 From: Harry Pierson Date: Thu, 28 May 2026 15:09:33 -0700 Subject: [PATCH 29/29] remove unused field --- .../main/java/dev/dbos/transact/execution/DBOSExecutor.java | 3 --- 1 file changed, 3 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 1e77eba81..d5161ff84 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -121,7 +121,6 @@ public String fqName() { private Map workflowMap; private Map instanceMap; private Map internalWorkflowMap; - private Map internalInstanceMap; private Map queueMap; private ConcurrentHashMap workflowsInProgress = new ConcurrentHashMap<>(); @@ -178,7 +177,6 @@ public void start( this.workflowMap = workflowMap; this.instanceMap = instanceMap; this.internalWorkflowMap = internalWorkflowMap; - this.internalInstanceMap = internalInstanceMap; this.queueMap = queues.stream().collect(Collectors.toUnmodifiableMap(Queue::name, queue -> queue)); this.listeners = listenerSet; @@ -327,7 +325,6 @@ public void close() { this.workflowMap = null; this.instanceMap = null; this.internalWorkflowMap = null; - this.internalInstanceMap = null; logger.debug("DBOS Executor stopped"); }