From ba41df3b776e4472aad1055e15da2e3b4d1019b1 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Wed, 4 Feb 2026 11:53:06 -0500 Subject: [PATCH 01/11] Checkpoint portable serialization --- .../src/main/java/dev/dbos/transact/DBOS.java | 71 ++++- .../java/dev/dbos/transact/DBOSClient.java | 169 ++++++++++- .../dbos/transact/context/DBOSContext.java | 17 ++ .../transact/database/NotificationsDAO.java | 103 +++++-- .../dev/dbos/transact/database/StepsDAO.java | 36 ++- .../transact/database/SystemDatabase.java | 22 +- .../dbos/transact/database/WorkflowDAO.java | 56 ++-- .../dbos/transact/execution/DBOSExecutor.java | 116 ++++--- .../transact/internal/WorkflowRegistry.java | 3 +- .../transact/json/DBOSJavaSerializer.java | 74 +++++ .../transact/json/DBOSPortableSerializer.java | 181 +++++++++++ .../dbos/transact/json/DBOSSerializer.java | 29 ++ .../dbos/transact/json/JsonWorkflowArgs.java | 19 ++ .../transact/json/JsonWorkflowErrorData.java | 12 + .../json/PortableWorkflowException.java | 45 +++ .../dbos/transact/json/SerializationUtil.java | 286 ++++++++++++++++++ .../transact/migrations/MigrationManager.java | 27 +- .../InternalWorkflowsService.java | 2 +- .../InternalWorkflowsServiceImpl.java | 14 +- .../workflow/SerializationStrategy.java | 51 ++++ .../dev/dbos/transact/workflow/StepInfo.java | 3 +- .../transact/workflow/WorkflowClassName.java | 30 ++ .../transact/workflow/WorkflowStatus.java | 3 +- .../workflow/internal/StepResult.java | 17 +- .../internal/WorkflowStatusInternal.java | 15 +- .../dbos/transact/admin/AdminServerTest.java | 7 +- .../transact/conductor/ConductorTest.java | 10 +- .../json/PortableSerializationTest.java | 268 ++++++++++++++++ .../transact/utils/WorkflowStatusBuilder.java | 9 +- .../transact/utils/WorkflowStatusRow.java | 6 +- 30 files changed, 1551 insertions(+), 150 deletions(-) create mode 100644 transact/src/main/java/dev/dbos/transact/json/DBOSJavaSerializer.java create mode 100644 transact/src/main/java/dev/dbos/transact/json/DBOSPortableSerializer.java create mode 100644 transact/src/main/java/dev/dbos/transact/json/DBOSSerializer.java create mode 100644 transact/src/main/java/dev/dbos/transact/json/JsonWorkflowArgs.java create mode 100644 transact/src/main/java/dev/dbos/transact/json/JsonWorkflowErrorData.java create mode 100644 transact/src/main/java/dev/dbos/transact/json/PortableWorkflowException.java create mode 100644 transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/SerializationStrategy.java create mode 100644 transact/src/main/java/dev/dbos/transact/workflow/WorkflowClassName.java create mode 100644 transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 0644b6bb..92e5663b 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -104,7 +104,13 @@ private void registerClassWorkflows( throw new IllegalStateException("Cannot register workflow after DBOS is launched"); } - String className = implementation.getClass().getName(); + // Use @WorkflowClassName annotation if present, otherwise use the Java class name + WorkflowClassName classNameAnnotation = + implementation.getClass().getAnnotation(WorkflowClassName.class); + String className = + (classNameAnnotation != null && !classNameAnnotation.value().isEmpty()) + ? classNameAnnotation.value() + : implementation.getClass().getName(); workflowRegistry.register(interfaceClass, implementation, className, instanceName); Method[] methods = implementation.getClass().getDeclaredMethods(); @@ -509,6 +515,17 @@ public static T getResult(@NonNull String workflowId) t return executor("getWorkflowStatus").getWorkflowStatus(workflowId); } + /** + * Get the serialization format of the current workflow context. + * + * @return the serialization format name (e.g., "portable_json", "java_jackson"), or null if not + * in a workflow context or using default serialization + */ + public static @Nullable String getSerialization() { + var ctx = DBOSContextHolder.get(); + return ctx != null ? ctx.getSerialization() : null; + } + /** * Send a message to a workflow * @@ -522,8 +539,33 @@ public static void send( @NonNull Object message, @NonNull String topic, @Nullable String idempotencyKey) { + send(destinationId, message, topic, idempotencyKey, null); + } + + /** + * Send a message to a workflow with serialization strategy + * + * @param destinationId recipient of the message + * @param message message to be sent + * @param topic topic to which the message is send + * @param idempotencyKey optional idempotency key for exactly-once send + * @param serialization serialization strategy to use (null for default) + */ + public static void send( + @NonNull String destinationId, + @NonNull Object message, + @NonNull String topic, + @Nullable String idempotencyKey, + @Nullable SerializationStrategy serialization) { + String serializationFormat = serialization != null ? serialization.formatName() : null; executor("send") - .send(destinationId, message, topic, instance().internalWorkflowsService, idempotencyKey); + .send( + destinationId, + message, + topic, + instance().internalWorkflowsService, + idempotencyKey, + serializationFormat); } /** @@ -535,7 +577,7 @@ public static void send( */ public static void send( @NonNull String destinationId, @NonNull Object message, @NonNull String topic) { - DBOS.send(destinationId, message, topic, null); + DBOS.send(destinationId, message, topic, null, null); } /** @@ -550,13 +592,32 @@ public static void send( } /** - * Call within a workflow to publish a key value pair + * Call within a workflow to publish a key value pair. Uses the workflow's serialization format. * * @param key identifier for published data * @param value data that is published */ public static void setEvent(@NonNull String key, @NonNull Object value) { - executor("setEvent").setEvent(key, value); + setEvent(key, value, null); + } + + /** + * Call within a workflow to publish a key value pair with a specific serialization strategy. + * + * @param key identifier for published data + * @param value data that is published + * @param serialization serialization strategy to use (null to use workflow's default) + */ + public static void setEvent( + @NonNull String key, @NonNull Object value, @Nullable SerializationStrategy serialization) { + // If no explicit serialization specified, use the workflow context's serialization + String serializationFormat; + if (serialization != null) { + serializationFormat = serialization.formatName(); + } else { + serializationFormat = getSerialization(); + } + executor("setEvent").setEvent(key, value, serializationFormat); } /** diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 8066937c..154e8a8e 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -3,8 +3,10 @@ import dev.dbos.transact.database.Result; import dev.dbos.transact.database.SystemDatabase; import dev.dbos.transact.execution.DBOSExecutor; +import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; +import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.Timeout; import dev.dbos.transact.workflow.WorkflowHandle; @@ -12,6 +14,8 @@ import dev.dbos.transact.workflow.WorkflowStatus; import dev.dbos.transact.workflow.internal.WorkflowStatusInternal; +import java.util.Map; + import java.time.Duration; import java.time.Instant; import java.util.List; @@ -123,7 +127,8 @@ public record EnqueueOptions( @Nullable Instant deadline, @Nullable String deduplicationId, @Nullable Integer priority, - @Nullable String queuePartitionKey) { + @Nullable String queuePartitionKey, + @Nullable SerializationStrategy serialization) { public EnqueueOptions { if (Objects.requireNonNull(workflowName, "EnqueueOptions workflowName must not be null") @@ -169,7 +174,7 @@ public record EnqueueOptions( /** Construct `EnqueueOptions` with a minimum set of required options */ public EnqueueOptions( @NonNull String className, @NonNull String workflowName, @NonNull String queueName) { - this(workflowName, queueName, className, "", null, null, null, null, null, null, null); + this(workflowName, queueName, className, "", null, null, null, null, null, null, null, null); } /** @@ -190,7 +195,8 @@ public EnqueueOptions( this.deadline, this.deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -212,7 +218,8 @@ public EnqueueOptions( this.deadline, this.deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -234,7 +241,8 @@ public EnqueueOptions( this.deadline, this.deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -256,7 +264,8 @@ public EnqueueOptions( this.deadline, this.deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -278,7 +287,8 @@ public EnqueueOptions( deadline, this.deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -300,7 +310,8 @@ public EnqueueOptions( this.deadline, deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -322,7 +333,8 @@ public EnqueueOptions( this.deadline, this.deduplicationId, this.priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -343,7 +355,8 @@ public EnqueueOptions( this.deadline, this.deduplicationId, priority, - this.queuePartitionKey); + this.queuePartitionKey, + this.serialization); } /** @@ -366,7 +379,32 @@ public EnqueueOptions( this.deadline, this.deduplicationId, this.priority, - partitionKey); + partitionKey, + this.serialization); + } + + /** + * Specify the serialization strategy for the workflow arguments. + * + * @param serialization The serialization strategy ({@link SerializationStrategy#PORTABLE} for + * cross-language compatibility, {@link SerializationStrategy#NATIVE} for Java-specific, or + * {@link SerializationStrategy#DEFAULT} for the default behavior) + * @return New `EnqueueOptions` with the serialization strategy set + */ + public @NonNull EnqueueOptions withSerialization(@Nullable SerializationStrategy serialization) { + return new EnqueueOptions( + this.workflowName, + this.queueName, + this.className, + this.instanceName, + this.workflowId, + this.appVersion, + this.timeout, + this.deadline, + this.deduplicationId, + this.priority, + this.queuePartitionKey, + serialization); } /** @@ -392,6 +430,9 @@ public EnqueueOptions( public @NonNull WorkflowHandle enqueueWorkflow( @NonNull EnqueueOptions options, @Nullable Object[] args) { + String serializationFormat = + options.serialization() != null ? options.serialization().formatName() : null; + return DBOSExecutor.enqueueWorkflow( Objects.requireNonNull( options.workflowName(), "EnqueueOptions workflowName must not be null"), @@ -409,7 +450,8 @@ public EnqueueOptions( options.priority, options.queuePartitionKey, false, - false), + false, + serializationFormat), null, null, null, @@ -417,6 +459,83 @@ public EnqueueOptions( systemDatabase); } + /** + * Enqueue a workflow using portable JSON serialization. This method is intended for cross-language + * workflow initiation where the workflow function definition may not be available in Java. Unlike + * {@link #enqueueWorkflow}, this method does not validate function names or arguments. + * + * @param Return type of workflow function + * @param Exception thrown by workflow function + * @param options `DBOSClient.EnqueueOptions` for enqueuing the workflow + * @param positionalArgs Positional arguments to pass to the workflow function + * @param namedArgs Optional named arguments (for workflows that support them, e.g., Python kwargs) + * @return WorkflowHandle for retrieving workflow ID, status, and results + */ + public @NonNull WorkflowHandle enqueuePortableWorkflow( + @NonNull EnqueueOptions options, + @Nullable Object[] positionalArgs, + @Nullable Map namedArgs) { + + String workflowId = + Objects.requireNonNullElseGet(options.workflowId(), () -> UUID.randomUUID().toString()); + + // Serialize arguments in portable format + SerializationUtil.SerializedResult serializedArgs = + SerializationUtil.serializeArgs( + positionalArgs, + namedArgs, + SerializationUtil.PORTABLE, + null); + + // Create workflow status directly with portable serialization + var statusBuilder = + WorkflowStatusInternal.builder(workflowId, WorkflowState.ENQUEUED) + .name(options.workflowName()) + .className(options.className()) + .instanceName(Objects.requireNonNullElse(options.instanceName(), "")) + .queueName(options.queueName()) + .inputs(serializedArgs.serializedValue()) + .serialization(serializedArgs.serialization()) + .createdAt(System.currentTimeMillis()) + .deduplicationId(options.deduplicationId()) + .priority(Objects.requireNonNullElse(options.priority(), 0)) + .queuePartitionKey(options.queuePartitionKey()) + .appVersion(options.appVersion()); + + if (options.timeout() != null) { + statusBuilder.timeoutMs(options.timeout().toMillis()); + } + if (options.deadline() != null) { + statusBuilder.deadlineEpochMs(options.deadline().toEpochMilli()); + } + + var status = statusBuilder.build(); + + systemDatabase.initWorkflowStatus(status, null, false, false); + + return new WorkflowHandleClient<>(workflowId); + } + + /** + * Options for sending a message. + */ + public record SendOptions(@Nullable SerializationStrategy serialization) { + /** Create SendOptions with default serialization. */ + public static SendOptions defaults() { + return new SendOptions(SerializationStrategy.DEFAULT); + } + + /** Create SendOptions with portable JSON serialization. */ + public static SendOptions portable() { + return new SendOptions(SerializationStrategy.PORTABLE); + } + + /** Create SendOptions with native Java serialization. */ + public static SendOptions nativeSerialization() { + return new SendOptions(SerializationStrategy.NATIVE); + } + } + /** * Send a message to a workflow * @@ -430,17 +549,41 @@ public void send( @NonNull Object message, @NonNull String topic, @Nullable String idempotencyKey) { + send(destinationId, message, topic, idempotencyKey, null); + } + + /** + * Send a message to a workflow with serialization options + * + * @param destinationId workflowId of the workflow to receive the message + * @param message Message contents + * @param topic Topic for the message + * @param idempotencyKey If specified, use the value to ensure exactly-once send semantics + * @param options Optional send options including serialization type + */ + public void send( + @NonNull String destinationId, + @NonNull Object message, + @NonNull String topic, + @Nullable String idempotencyKey, + @Nullable SendOptions options) { if (idempotencyKey == null) { idempotencyKey = UUID.randomUUID().toString(); } var workflowId = "%s-%s".formatted(destinationId, idempotencyKey); + String serializationFormat = + (options != null && options.serialization() != null) + ? options.serialization().formatName() + : null; + var status = WorkflowStatusInternal.builder(workflowId, WorkflowState.SUCCESS) .name("temp_workflow-send-client") + .serialization(serializationFormat != null ? serializationFormat : SerializationUtil.NATIVE) .build(); systemDatabase.initWorkflowStatus(status, null, false, false); - systemDatabase.send(status.workflowId(), 0, destinationId, message, topic); + systemDatabase.send(status.workflowId(), 0, destinationId, message, topic, serializationFormat); } /** 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 a2cc3e0e..1549e616 100644 --- a/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java +++ b/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java @@ -21,6 +21,7 @@ public class DBOSContext { private final WorkflowInfo parent; private final Duration timeout; private final Instant deadline; + private final String serialization; // private StepStatus stepStatus; @@ -30,14 +31,25 @@ public DBOSContext() { parent = null; timeout = null; deadline = null; + serialization = null; } public DBOSContext(String workflowId, WorkflowInfo parent, Duration timeout, Instant deadline) { + this(workflowId, parent, timeout, deadline, null); + } + + public DBOSContext( + String workflowId, + WorkflowInfo parent, + Duration timeout, + Instant deadline, + String serialization) { this.workflowId = workflowId; this.functionId = 0; this.parent = parent; this.timeout = timeout; this.deadline = deadline; + this.serialization = serialization; } public DBOSContext( @@ -54,6 +66,7 @@ public DBOSContext( this.parent = other.parent; this.timeout = other.timeout; this.deadline = other.deadline; + this.serialization = other.serialization; } public boolean isInWorkflow() { @@ -122,6 +135,10 @@ public Instant getDeadline() { return deadline; } + public String getSerialization() { + return serialization; + } + public static String workflowId() { var ctx = DBOSContextHolder.get(); return ctx == null ? null : ctx.workflowId; diff --git a/transact/src/main/java/dev/dbos/transact/database/NotificationsDAO.java b/transact/src/main/java/dev/dbos/transact/database/NotificationsDAO.java index 290454e8..85b410a2 100644 --- a/transact/src/main/java/dev/dbos/transact/database/NotificationsDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/NotificationsDAO.java @@ -4,6 +4,7 @@ import dev.dbos.transact.exceptions.DBOSNonExistentWorkflowException; import dev.dbos.transact.exceptions.DBOSWorkflowExecutionConflictException; import dev.dbos.transact.json.JSONUtil; +import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.internal.StepResult; import java.sql.Connection; @@ -39,7 +40,12 @@ void speedUpPollingForTest() { } void send( - String workflowUuid, int functionId, String destinationUuid, Object message, String topic) + String workflowUuid, + int functionId, + String destinationUuid, + Object message, + String topic, + String serialization) throws SQLException { var startTime = System.currentTimeMillis(); @@ -71,17 +77,22 @@ void send( finalTopic); } - // Insert notification + // Serialize the message using the specified format + SerializationUtil.SerializedResult serializedMsg = + SerializationUtil.serializeValue(message, serialization, null); + + // Insert notification with serialization format final String sql = """ - INSERT INTO %s.notifications (destination_uuid, topic, message) VALUES (?, ?, ?) + INSERT INTO %s.notifications (destination_uuid, topic, message, serialization) VALUES (?, ?, ?, ?) """ .formatted(this.schema); try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, destinationUuid); stmt.setString(2, finalTopic); - stmt.setString(3, JSONUtil.serialize(message)); + stmt.setString(3, serializedMsg.serializedValue()); + stmt.setString(4, serializedMsg.serialization()); stmt.executeUpdate(); } catch (SQLException e) { // Foreign key violation @@ -92,7 +103,7 @@ void send( } // Record operation result - var output = new StepResult(workflowUuid, functionId, functionName); + var output = new StepResult(workflowUuid, functionId, functionName, null, null, null, null); StepsDAO.recordStepResultTxn( output, startTime, System.currentTimeMillis(), conn, this.schema); @@ -128,8 +139,8 @@ Object recv( if (recordedOutput != null) { logger.debug("Replaying recv, id: {}, topic: {}", functionId, finalTopic); if (recordedOutput.output() != null) { - Object[] dSerOut = JSONUtil.deserializeToArray(recordedOutput.output()); - return dSerOut == null ? null : dSerOut[0]; + return SerializationUtil.deserializeValue( + recordedOutput.output(), recordedOutput.serialization(), null); } else { throw new RuntimeException("No output recorded in the last recv"); } @@ -213,7 +224,7 @@ Object recv( final String sql = """ WITH oldest_entry AS ( - SELECT destination_uuid, topic, message, created_at_epoch_ms + SELECT destination_uuid, topic, message, serialization, created_at_epoch_ms FROM %1$s.notifications WHERE destination_uuid = ? AND topic = ? ORDER BY created_at_epoch_ms ASC @@ -223,11 +234,11 @@ WITH oldest_entry AS ( WHERE destination_uuid = (SELECT destination_uuid FROM oldest_entry) AND topic = (SELECT topic FROM oldest_entry) AND created_at_epoch_ms = (SELECT created_at_epoch_ms FROM oldest_entry) - RETURNING message + RETURNING message, serialization """ .formatted(this.schema); - Object[] recvdSermessage = null; + Object recvdMessage = null; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, workflowUuid); stmt.setString(2, finalTopic); @@ -235,15 +246,17 @@ WITH oldest_entry AS ( try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { String serializedMessage = rs.getString("message"); - recvdSermessage = JSONUtil.deserializeToArray(serializedMessage); + String serialization = rs.getString("serialization"); + recvdMessage = + SerializationUtil.deserializeValue(serializedMessage, serialization, null); } } } // Record operation result - Object toSave = recvdSermessage == null ? null : recvdSermessage[0]; + Object toSave = recvdMessage; StepResult output = - new StepResult(workflowUuid, functionId, functionName) + new StepResult(workflowUuid, functionId, functionName, null, null, null, null) .withOutput(JSONUtil.serialize(toSave)); StepsDAO.recordStepResultTxn( output, startTime, System.currentTimeMillis(), conn, this.schema); @@ -259,14 +272,19 @@ WITH oldest_entry AS ( } private void setEvent( - Connection conn, String workflowId, int functionId, String key, String message) + Connection conn, + String workflowId, + int functionId, + String key, + String message, + String serialization) throws SQLException { final String eventSql = """ - INSERT INTO %s.workflow_events (workflow_uuid, key, value) - VALUES (?, ?, ?) + INSERT INTO %s.workflow_events (workflow_uuid, key, value, serialization) + VALUES (?, ?, ?, ?) ON CONFLICT (workflow_uuid, key) - DO UPDATE SET value = EXCLUDED.value + DO UPDATE SET value = EXCLUDED.value, serialization = EXCLUDED.serialization """ .formatted(this.schema); @@ -274,15 +292,16 @@ ON CONFLICT (workflow_uuid, key) stmt.setString(1, workflowId); stmt.setString(2, key); stmt.setString(3, message); + stmt.setString(4, serialization); stmt.executeUpdate(); } final String eventHistorySql = """ - INSERT INTO %s.workflow_events_history (workflow_uuid, function_id, key, value) - VALUES (?, ?, ?, ?) + INSERT INTO %s.workflow_events_history (workflow_uuid, function_id, key, value, serialization) + VALUES (?, ?, ?, ?, ?) ON CONFLICT (workflow_uuid, key, function_id) - DO UPDATE SET value = EXCLUDED.value + DO UPDATE SET value = EXCLUDED.value, serialization = EXCLUDED.serialization """ .formatted(this.schema); @@ -291,16 +310,26 @@ ON CONFLICT (workflow_uuid, key, function_id) stmt.setInt(2, functionId); stmt.setString(3, key); stmt.setString(4, message); + stmt.setString(5, serialization); stmt.executeUpdate(); } } - void setEvent(String workflowId, int functionId, String key, Object message, boolean asStep) + void setEvent( + String workflowId, + int functionId, + String key, + Object message, + boolean asStep, + String serialization) throws SQLException { var startTime = System.currentTimeMillis(); String functionName = "DBOS.setEvent"; - String serializedMessage = JSONUtil.serialize(message); + + // Serialize the message using the specified format + SerializationUtil.SerializedResult serializedResult = + SerializationUtil.serializeValue(message, serialization, null); try (Connection conn = dataSource.getConnection()) { conn.setAutoCommit(false); @@ -321,11 +350,18 @@ void setEvent(String workflowId, int functionId, String key, Object message, boo } } - this.setEvent(conn, workflowId, functionId, key, serializedMessage); + this.setEvent( + conn, + workflowId, + functionId, + key, + serializedResult.serializedValue(), + serializedResult.serialization()); if (asStep) { // Record the operation result - StepResult output = new StepResult(workflowId, functionId, functionName); + StepResult output = + new StepResult(workflowId, functionId, functionName, null, null, null, null); StepsDAO.recordStepResultTxn( output, startTime, System.currentTimeMillis(), conn, this.schema); } @@ -361,8 +397,8 @@ Object getEvent( if (recordedOutput != null) { logger.debug("Replaying getEvent, id: {}, key: {}", callerCtx.functionId(), key); if (recordedOutput.output() != null) { - Object[] outputArray = JSONUtil.deserializeToArray(recordedOutput.output()); - return outputArray == null ? null : outputArray[0]; + return SerializationUtil.deserializeValue( + recordedOutput.output(), recordedOutput.serialization(), null); } else { throw new RuntimeException("No output recorded in the last getEvent"); } @@ -382,7 +418,7 @@ Object getEvent( Object value = null; final String sql = """ - SELECT value FROM %s.workflow_events WHERE workflow_uuid = ? AND key = ? + SELECT value, serialization FROM %s.workflow_events WHERE workflow_uuid = ? AND key = ? """ .formatted(this.schema); @@ -405,8 +441,8 @@ Object getEvent( try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { String serializedValue = rs.getString("value"); - Object[] valueArray = JSONUtil.deserializeToArray(serializedValue); - value = valueArray == null ? null : valueArray[0]; + String serialization = rs.getString("serialization"); + value = SerializationUtil.deserializeValue(serializedValue, serialization, null); hasExistingNotification = true; } } @@ -445,7 +481,14 @@ Object getEvent( // Record the output if it's in a workflow if (callerCtx != null) { StepResult output = - new StepResult(callerCtx.workflowId(), callerCtx.functionId(), functionName) + new StepResult( + callerCtx.workflowId(), + callerCtx.functionId(), + functionName, + null, + null, + null, + null) .withOutput(JSONUtil.serialize(value)); StepsDAO.recordStepResultTxn( dataSource, output, startTime, System.currentTimeMillis(), this.schema); diff --git a/transact/src/main/java/dev/dbos/transact/database/StepsDAO.java b/transact/src/main/java/dev/dbos/transact/database/StepsDAO.java index 1b1b1433..7f264621 100644 --- a/transact/src/main/java/dev/dbos/transact/database/StepsDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/StepsDAO.java @@ -3,6 +3,7 @@ import dev.dbos.transact.exceptions.*; import dev.dbos.transact.internal.DebugTriggers; import dev.dbos.transact.json.JSONUtil; +import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ErrorResult; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.WorkflowState; @@ -157,7 +158,7 @@ static StepResult checkStepExecutionTxn( String operationOutputSql = """ - SELECT output, error, function_name + SELECT output, error, function_name, serialization FROM %s.operation_outputs WHERE workflow_uuid = ? AND function_id = ? """ @@ -174,8 +175,10 @@ static StepResult checkStepExecutionTxn( String output = rs.getString("output"); String error = rs.getString("error"); recordedFunctionName = rs.getString("function_name"); + String serialization = rs.getString("serialization"); recordedResult = - new StepResult(workflowId, functionId, recordedFunctionName, output, error, null); + new StepResult( + workflowId, functionId, recordedFunctionName, output, error, null, serialization); } } } @@ -196,7 +199,7 @@ List listWorkflowSteps(String workflowId) throws SQLException { final String sql = """ - SELECT function_id, function_name, output, error, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms + SELECT function_id, function_name, output, error, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization FROM %s.operation_outputs WHERE workflow_uuid = ? ORDER BY function_id; @@ -220,12 +223,13 @@ List listWorkflowSteps(String workflowId) throws SQLException { String childWorkflowId = rs.getString("child_workflow_id"); Long startedAt = rs.getObject("started_at_epoch_ms", Long.class); Long completedAt = rs.getObject("completed_at_epoch_ms", Long.class); + String serialization = rs.getString("serialization"); // Deserialize output if present - Object[] output = null; + Object outputVal = null; if (outputData != null) { try { - output = JSONUtil.deserializeToArray(outputData); + outputVal = SerializationUtil.deserializeValue(outputData, serialization, null); } catch (Exception e) { throw new RuntimeException( "Failed to deserialize output for function " + functionId, e); @@ -235,18 +239,17 @@ List listWorkflowSteps(String workflowId) throws SQLException { // Deserialize error if present ErrorResult stepError = null; if (errorData != null) { - Exception error = null; + Throwable error = null; try { - error = (Exception) JSONUtil.deserializeAppException(errorData); + error = SerializationUtil.deserializeError(errorData, serialization, null); } catch (Exception e) { throw new RuntimeException( "Failed to deserialize error for function " + functionId, e); } - var errorWrapper = JSONUtil.deserializeAppExceptionWrapper(errorData); - stepError = new ErrorResult(errorWrapper.type, errorWrapper.message, errorData, error); + String errorClassName = error.getClass().getName(); + String errorMessage = error.getMessage(); + stepError = new ErrorResult(errorClassName, errorMessage, errorData, error); } - - Object outputVal = output != null ? output[0] : null; steps.add( new StepInfo( functionId, @@ -255,7 +258,8 @@ List listWorkflowSteps(String workflowId) throws SQLException { stepError, childWorkflowId, startedAt, - completedAt)); + completedAt, + serialization)); } } } @@ -297,8 +301,10 @@ static Duration durableSleepDuration( if (recordedOutput.output() == null) { throw new IllegalStateException("No recorded timeout for sleep"); } - Object[] dser = JSONUtil.deserializeToArray(recordedOutput.output()); - endTime = (long) dser[0]; + Object deserialized = + SerializationUtil.deserializeValue( + recordedOutput.output(), recordedOutput.serialization(), null); + endTime = ((Number) deserialized).longValue(); } else { logger.debug( "Running sleep, workflow {}, id: {}, duration: {}", workflowUuid, functionId, duration); @@ -306,7 +312,7 @@ static Duration durableSleepDuration( try { StepResult output = - new StepResult(workflowUuid, functionId, functionName) + new StepResult(workflowUuid, functionId, functionName, null, null, null, null) .withOutput(JSONUtil.serialize(endTime)); recordStepResultTxn(dataSource, output, startTime, (long) endTime, schema); } catch (DBOSWorkflowExecutionConflictException e) { diff --git a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java index 7de4b78a..93b55e3c 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -338,11 +338,17 @@ public Optional checkChildWorkflow(String workflowUuid, int functionId) } public void send( - String workflowId, int functionId, String destinationId, Object message, String topic) { + String workflowId, + int functionId, + String destinationId, + Object message, + String topic, + String serialization) { dbRetry( () -> { - notificationsDAO.send(workflowId, functionId, destinationId, message, topic); + notificationsDAO.send( + workflowId, functionId, destinationId, message, topic, serialization); return null; }); } @@ -357,11 +363,16 @@ public Object recv( } public void setEvent( - String workflowId, int functionId, String key, Object message, boolean asStep) { + String workflowId, + int functionId, + String key, + Object message, + boolean asStep, + String serialization) { dbRetry( () -> { - notificationsDAO.setEvent(workflowId, functionId, key, message, asStep); + notificationsDAO.setEvent(workflowId, functionId, key, message, asStep, serialization); return null; }); } @@ -578,7 +589,8 @@ public boolean patch(String workflowId, int functionId, String patchName) { try (Connection conn = dataSource.getConnection()) { var checkpointName = getCheckpointName(conn, workflowId, functionId); if (checkpointName == null) { - var output = new StepResult(workflowId, functionId, patchName); + var output = + new StepResult(workflowId, functionId, patchName, null, null, null, null); StepsDAO.recordStepResultTxn( output, System.currentTimeMillis(), null, conn, this.schema); return true; diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java index 3298646d..e6e846df 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java @@ -4,6 +4,7 @@ import dev.dbos.transact.exceptions.*; import dev.dbos.transact.internal.DebugTriggers; import dev.dbos.transact.json.JSONUtil; +import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ErrorResult; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; @@ -170,8 +171,8 @@ InsertWorkflowResult insertWorkflowStatus( executor_id, application_version, application_id, created_at, updated_at, recovery_attempts, workflow_timeout_ms, workflow_deadline_epoch_ms, - owner_xid - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + owner_xid, serialization + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (workflow_uuid) DO UPDATE SET recovery_attempts = CASE @@ -224,7 +225,8 @@ ON CONFLICT (workflow_uuid) stmt.setObject(21, status.deadlineEpochMs()); stmt.setObject(22, ownerXid); - stmt.setInt(23, incrementAttempts ? 1 : 0); + stmt.setString(23, status.serialization()); + stmt.setInt(24, incrementAttempts ? 1 : 0); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -348,7 +350,7 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException executor_id, application_version, application_id, authenticated_user, assumed_role, authenticated_roles, created_at, updated_at, recovery_attempts, started_at_epoch_ms, - workflow_timeout_ms, workflow_deadline_epoch_ms + workflow_timeout_ms, workflow_deadline_epoch_ms, serialization """); var loadInput = input.loadInput() == null || input.loadInput(); @@ -479,18 +481,30 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException String serializedInput = loadInput ? rs.getString("inputs") : null; String serializedOutput = loadOutput ? rs.getString("output") : null; String serializedError = loadOutput ? rs.getString("error") : null; + String serialization = rs.getString("serialization"); ErrorResult err = null; if (serializedError != null) { - var wrapper = JSONUtil.deserializeAppExceptionWrapper(serializedError); Throwable throwable = null; try { - throwable = JSONUtil.deserializeAppException(serializedError); + throwable = SerializationUtil.deserializeError(serializedError, serialization, null); } catch (Exception e) { throw new RuntimeException( "Failed to deserialize error for workflow " + workflow_uuid, e); } - err = new ErrorResult(wrapper.type, wrapper.message, serializedError, throwable); + String errorClassName = throwable.getClass().getName(); + String errorMessage = throwable.getMessage(); + err = new ErrorResult(errorClassName, errorMessage, serializedError, throwable); } + // Deserialize input and output using serialization format + Object[] inputArray = + (serializedInput != null) + ? SerializationUtil.deserializePositionalArgs( + serializedInput, serialization, null) + : null; + Object outputValue = + (serializedOutput != null) + ? SerializationUtil.deserializeValue(serializedOutput, serialization, null) + : null; WorkflowStatus info = new WorkflowStatus( workflow_uuid, @@ -503,10 +517,8 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException (authenticatedRolesJson != null) ? (String[]) JSONUtil.deserializeToArray(authenticatedRolesJson) : null, - (serializedInput != null) ? JSONUtil.deserializeToArray(serializedInput) : null, - (serializedOutput != null) - ? JSONUtil.deserializeToArray(serializedOutput)[0] - : null, + inputArray, + outputValue, err, rs.getString("executor_id"), rs.getObject("created_at", Long.class), @@ -521,7 +533,8 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException rs.getString("deduplication_id"), rs.getObject("priority", Integer.class), rs.getString("queue_partition_key"), - rs.getString("forked_from")); + rs.getString("forked_from"), + rs.getString("serialization")); workflows.add(info); } @@ -570,7 +583,7 @@ Result awaitWorkflowResult(String workflowId) throws SQLException { final String sql = """ - SELECT status, output, error + SELECT status, output, error, serialization FROM %s.workflow_status WHERE workflow_uuid = ? """ @@ -585,16 +598,18 @@ Result awaitWorkflowResult(String workflowId) throws SQLException { try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { String status = rs.getString("status"); + String serialization = rs.getString("serialization"); switch (WorkflowState.valueOf(status.toUpperCase())) { case SUCCESS: String output = rs.getString("output"); - Object[] oArray = JSONUtil.deserializeToArray(output); - return Result.success((T) oArray[0]); + Object outputValue = + SerializationUtil.deserializeValue(output, serialization, null); + return Result.success((T) outputValue); case ERROR: String error = rs.getString("error"); - Throwable t = JSONUtil.deserializeAppException(error); + Throwable t = SerializationUtil.deserializeError(error, serialization, null); return Result.failure(t); case CANCELLED: throw new DBOSAwaitedWorkflowCancelledException(workflowId); @@ -625,7 +640,9 @@ void recordChildWorkflow( long startTime) throws SQLException { - var result = new StepResult(parentId, functionId, functionName).withChildWorkflowId(childId); + var result = + new StepResult(parentId, functionId, functionName, null, null, null, null) + .withChildWorkflowId(childId); try (Connection connection = dataSource.getConnection()) { StepsDAO.recordStepResultTxn(result, null, null, connection, schema); } @@ -810,8 +827,8 @@ private static void insertForkedWorkflowStatus( """ INSERT INTO %s.workflow_status ( workflow_uuid, status, name, class_name, config_name, application_version, application_id, - authenticated_user, authenticated_roles, assumed_role, queue_name, inputs, workflow_timeout_ms, forked_from - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + authenticated_user, authenticated_roles, assumed_role, queue_name, inputs, workflow_timeout_ms, forked_from, serialization + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) """ .formatted(schema); @@ -830,6 +847,7 @@ private static void insertForkedWorkflowStatus( stmt.setString(12, JSONUtil.serializeArray(originalStatus.input())); stmt.setObject(13, timeoutMS); stmt.setString(14, originalWorkflowId); + stmt.setString(15, originalStatus.serialization()); stmt.executeUpdate(); } 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 a5b5e470..d8aedc6d 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -20,6 +20,7 @@ import dev.dbos.transact.internal.DBOSInvocationHandler; import dev.dbos.transact.internal.Invocation; import dev.dbos.transact.json.JSONUtil; +import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.tempworkflows.InternalWorkflowsService; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; @@ -381,18 +382,17 @@ public Optional getQueue(String queueName) { } private static void postInvokeWorkflowResult( - SystemDatabase systemDatabase, String workflowId, Object result) { + SystemDatabase systemDatabase, String workflowId, Object result, String serialization) { - String resultString = JSONUtil.serialize(result); - systemDatabase.recordWorkflowOutput(workflowId, resultString); + var serialized = SerializationUtil.serializeValue(result, serialization, null); + systemDatabase.recordWorkflowOutput(workflowId, serialized.serializedValue()); } private static void postInvokeWorkflowError( - SystemDatabase systemDatabase, String workflowId, Throwable error) { + SystemDatabase systemDatabase, String workflowId, Throwable error, String serialization) { - String errorString = JSONUtil.serializeAppException(error); - - systemDatabase.recordWorkflowError(workflowId, errorString); + var serialized = SerializationUtil.serializeError(error, serialization, null); + systemDatabase.recordWorkflowError(workflowId, serialized.serializedValue()); } /** This does not retry */ @@ -425,7 +425,7 @@ public T callFunctionAsStep( String jsonError = JSONUtil.serializeAppException(e); StepResult r = new StepResult( - ctx.getWorkflowId(), nextFuncId, functionName, null, jsonError, childWfId); + ctx.getWorkflowId(), nextFuncId, functionName, null, jsonError, childWfId, null); systemDatabase.recordStepResultTxn(r, startTime); } throw (E) e; @@ -434,7 +434,8 @@ public T callFunctionAsStep( // Record the successful result String jsonOutput = JSONUtil.serialize(functionResult); StepResult o = - new StepResult(ctx.getWorkflowId(), nextFuncId, functionName, jsonOutput, null, childWfId); + new StepResult( + ctx.getWorkflowId(), nextFuncId, functionName, jsonOutput, null, childWfId, null); systemDatabase.recordStepResultTxn(o, startTime); return functionResult; @@ -464,10 +465,12 @@ public T runStepInternal( private T handleExistingResult(StepResult result, String functionName) throws E { if (result.output() != null) { - Object[] resArray = JSONUtil.deserializeToArray(result.output()); - return resArray == null ? null : (T) resArray[0]; + Object outputValue = + SerializationUtil.deserializeValue(result.output(), result.serialization(), null); + return (T) outputValue; } else if (result.error() != null) { - Throwable t = JSONUtil.deserializeAppException(result.error()); + Throwable t = + SerializationUtil.deserializeError(result.error(), result.serialization(), null); if (t instanceof Exception) { throw (E) t; } else { @@ -519,13 +522,15 @@ public T runStepInternal( if (recordedResult != null) { String output = recordedResult.output(); if (output != null) { - Object[] stepO = JSONUtil.deserializeToArray(output); - return stepO == null ? null : (T) stepO[0]; + Object outputValue = + SerializationUtil.deserializeValue(output, recordedResult.serialization(), null); + return (T) outputValue; } String error = recordedResult.error(); if (error != null) { - var throwable = JSONUtil.deserializeAppException(error); + var throwable = + SerializationUtil.deserializeError(error, recordedResult.serialization(), null); if (!(throwable instanceof Exception)) throw new RuntimeException(throwable.getMessage(), throwable); throw (E) throwable; @@ -570,7 +575,8 @@ public T runStepInternal( if (eThrown == null) { StepResult stepResult = - new StepResult(workflowId, stepFunctionId, stepName, serializedOutput, null, childWfId); + new StepResult( + workflowId, stepFunctionId, stepName, serializedOutput, null, childWfId, null); systemDatabase.recordStepResultTxn(stepResult, startTime); return result; } else { @@ -581,7 +587,8 @@ public T runStepInternal( stepName, null, JSONUtil.serializeAppException(eThrown), - childWfId); + childWfId, + null); systemDatabase.recordStepResultTxn(stepResult, startTime); throw (E) eThrown; } @@ -668,7 +675,8 @@ public void send( Object message, String topic, InternalWorkflowsService internalWorkflowsService, - String idempotencyKey) { + String idempotencyKey, + String serialization) { DBOSContext ctx = DBOSContextHolder.get(); if (ctx.isInStep()) { @@ -678,7 +686,7 @@ public void send( var sendWfid = idempotencyKey == null ? null : "%s-%s".formatted(destinationId, idempotencyKey); try (var wfid = new WorkflowOptions(sendWfid).setContext()) { - internalWorkflowsService.sendWorkflow(destinationId, message, topic); + internalWorkflowsService.sendWorkflow(destinationId, message, topic, serialization); } return; } @@ -689,7 +697,8 @@ public void send( } int stepFunctionId = ctx.getAndIncrementFunctionId(); - systemDatabase.send(ctx.getWorkflowId(), stepFunctionId, destinationId, message, topic); + systemDatabase.send( + ctx.getWorkflowId(), stepFunctionId, destinationId, message, topic, serialization); } /** @@ -714,7 +723,7 @@ public Object recv(String topic, Duration timeout) { ctx.getWorkflowId(), stepFunctionId, timeoutFunctionId, topic, timeout); } - public void setEvent(String key, Object value) { + public void setEvent(String key, Object value, String serialization) { logger.debug("Received setEvent for key {}", key); DBOSContext ctx = DBOSContextHolder.get(); @@ -724,7 +733,7 @@ public void setEvent(String key, Object value) { var asStep = !ctx.isInStep(); var stepId = ctx.isInStep() ? ctx.getCurrentFunctionId() : ctx.getAndIncrementFunctionId(); - systemDatabase.setEvent(ctx.getWorkflowId(), stepId, key, value, asStep); + systemDatabase.setEvent(ctx.getWorkflowId(), stepId, key, value, asStep, serialization); } public Object getEvent(String workflowId, String key, Duration timeout) { @@ -892,7 +901,8 @@ public record ExecutionOptions( Integer priority, String queuePartitionKey, boolean isRecoveryRequest, - boolean isDequeuedRequest) { + boolean isDequeuedRequest, + String serialization) { public ExecutionOptions { if (timeout instanceof Timeout.Explicit explicit) { if (explicit.value().isNegative() || explicit.value().isZero()) { @@ -922,7 +932,7 @@ public record ExecutionOptions( } public ExecutionOptions(String workflowId, Duration timeout, Instant deadline) { - this(workflowId, Timeout.of(timeout), deadline, null, null, null, null, false, false); + this(workflowId, Timeout.of(timeout), deadline, null, null, null, null, false, false, null); } public ExecutionOptions asRecoveryRequest() { @@ -935,7 +945,8 @@ public ExecutionOptions asRecoveryRequest() { this.priority, this.queuePartitionKey, true, - false); + false, + this.serialization); } public ExecutionOptions asDequeuedRequest() { @@ -948,7 +959,22 @@ public ExecutionOptions asDequeuedRequest() { this.priority, this.queuePartitionKey, false, - true); + true, + this.serialization); + } + + public ExecutionOptions withSerialization(String serialization) { + return new ExecutionOptions( + this.workflowId, + this.timeout, + this.deadline, + this.queueName, + this.deduplicationId, + this.priority, + this.queuePartitionKey, + this.isRecoveryRequest, + this.isDequeuedRequest, + serialization); } public Duration timeoutDuration() { @@ -976,7 +1002,8 @@ public WorkflowHandle startWorkflow( options.priority(), options.queuePartitionKey(), false, - false); + false, + null); return executeWorkflow(regWorkflow, args, execOptions, null); } @@ -1027,7 +1054,8 @@ public WorkflowHandle startWorkflow( options.priority(), options.queuePartitionKey(), false, - false); + false, + null); return executeWorkflow(workflow, invocation.args(), execOptions, parent); } @@ -1091,7 +1119,9 @@ public WorkflowHandle executeWorkflowById( throw new DBOSWorkflowFunctionNotFoundException(workflowId, wfName); } - var options = new ExecutionOptions(workflowId, status.timeout(), status.deadline()); + var options = + new ExecutionOptions(workflowId, status.timeout(), status.deadline()) + .withSerialization(status.serialization()); if (isRecoveryRequest) options = options.asRecoveryRequest(); if (isDequeuedRequest) options = options.asDequeuedRequest(); return executeWorkflow(workflow, inputs, options, null); @@ -1170,7 +1200,8 @@ private WorkflowHandle executeWorkflow( options.timeoutDuration(), options.deadline(), options.isRecoveryRequest, - options.isDequeuedRequest); + options.isDequeuedRequest, + options.serialization()); if (!initResult.shouldExecuteOnThisExecutor()) { return retrieveWorkflow(workflowId); } @@ -1192,7 +1223,12 @@ private WorkflowHandle executeWorkflow( "executeWorkflow task {}({}) {}", workflow.fullyQualifiedName(), args, options); DBOSContextHolder.set( - new DBOSContext(workflowId, parent, options.timeoutDuration(), options.deadline())); + new DBOSContext( + workflowId, + parent, + options.timeoutDuration(), + options.deadline(), + options.serialization())); if (Thread.currentThread().isInterrupted()) { logger.debug("executeWorkflow task interrupted before workflow.invoke"); return null; @@ -1202,7 +1238,7 @@ private WorkflowHandle executeWorkflow( logger.debug("executeWorkflow task interrupted before postInvokeWorkflowResult"); return null; } - postInvokeWorkflowResult(systemDatabase, workflowId, result); + postInvokeWorkflowResult(systemDatabase, workflowId, result, options.serialization()); return result; } catch (DBOSWorkflowExecutionConflictException e) { // don't persist execution conflict exception @@ -1227,7 +1263,7 @@ private WorkflowHandle executeWorkflow( throw new DBOSAwaitedWorkflowCancelledException(workflowId); } - postInvokeWorkflowError(systemDatabase, workflowId, actual); + postInvokeWorkflowError(systemDatabase, workflowId, actual, options.serialization()); throw e; } finally { DBOSContextHolder.clear(); @@ -1305,7 +1341,8 @@ public static WorkflowHandle enqueueWorkflow( options.timeoutDuration(), options.deadline(), options.isRecoveryRequest, - options.isDequeuedRequest); + options.isDequeuedRequest, + options.serialization()); return new WorkflowHandleDBPoll(workflowId); } catch (DBOSWorkflowExecutionConflictException e) { logger.debug("Workflow execution conflict for workflowId {}", workflowId); @@ -1336,12 +1373,16 @@ private static WorkflowInitResult preInvokeWorkflow( Duration timeout, Instant deadline, boolean isRecoveryRequest, - boolean isDequeuedRequest) { + boolean isDequeuedRequest, + String serialization) { if (inputs == null) { inputs = new Object[0]; } - String inputString = JSONUtil.serializeArray(inputs); + // Serialize inputs using the specified serialization format + var serializedArgs = SerializationUtil.serializeArgs(inputs, null, serialization, null); + String inputString = serializedArgs.serializedValue(); + String actualSerialization = serializedArgs.serialization(); var startTime = System.currentTimeMillis(); WorkflowState status = queueName == null ? WorkflowState.PENDING : WorkflowState.ENQUEUED; @@ -1378,7 +1419,8 @@ private static WorkflowInitResult preInvokeWorkflow( null, null, timeoutMs, - deadlineEpochMs); + deadlineEpochMs, + actualSerialization); WorkflowInitResult[] initResult = {null}; initResult[0] = 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 6491292f..cf433007 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java +++ b/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java @@ -33,7 +33,8 @@ public void register( var fqName = RegisteredWorkflow.fullyQualifiedName(className, instanceName, workflowName); var regWorkflow = - new RegisteredWorkflow(workflowName, target, instanceName, method, maxRecoveryAttempts); + new RegisteredWorkflow( + workflowName, className, instanceName, target, method, maxRecoveryAttempts); SchedulerService.validateScheduledWorkflow(regWorkflow); var previous = wfRegistry.putIfAbsent(fqName, regWorkflow); diff --git a/transact/src/main/java/dev/dbos/transact/json/DBOSJavaSerializer.java b/transact/src/main/java/dev/dbos/transact/json/DBOSJavaSerializer.java new file mode 100644 index 00000000..e8ac4faa --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/DBOSJavaSerializer.java @@ -0,0 +1,74 @@ +package dev.dbos.transact.json; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Native Java serializer using Jackson with type information. This is the default serializer for + * Java DBOS applications. + */ +public class DBOSJavaSerializer implements DBOSSerializer { + + public static final String NAME = "java_jackson"; + + public static final DBOSJavaSerializer INSTANCE = new DBOSJavaSerializer(); + + private final ObjectMapper mapper; + + public DBOSJavaSerializer() { + this.mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String stringify(Object value) { + try { + return mapper.writeValueAsString(new Boxed(new Object[] {value})); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + @Override + public Object parse(String text) { + if (text == null) { + return null; + } + try { + Boxed boxed = mapper.readValue(text, Boxed.class); + return boxed.args != null && boxed.args.length > 0 ? boxed.args[0] : null; + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** Serialize an array of values (for workflow arguments). */ + public String stringifyArray(Object[] values) { + try { + return mapper.writeValueAsString(new Boxed(values)); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** Deserialize to an array of values (for workflow arguments). */ + public Object[] parseArray(String text) { + if (text == null) { + return null; + } + try { + Boxed boxed = mapper.readValue(text, Boxed.class); + return boxed.args; + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } +} diff --git a/transact/src/main/java/dev/dbos/transact/json/DBOSPortableSerializer.java b/transact/src/main/java/dev/dbos/transact/json/DBOSPortableSerializer.java new file mode 100644 index 00000000..ad27c344 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/DBOSPortableSerializer.java @@ -0,0 +1,181 @@ +package dev.dbos.transact.json; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; + +/** + * Portable JSON serializer that produces output compatible with any language. Does not include + * Java-specific type information. + * + *

Dates are serialized as ISO-8601 strings. Maps and Sets are serialized as plain JSON + * objects/arrays. Does not preserve Java class information. + */ +public class DBOSPortableSerializer implements DBOSSerializer { + + public static final String NAME = "portable_json"; + + public static final DBOSPortableSerializer INSTANCE = new DBOSPortableSerializer(); + + private final ObjectMapper mapper; + + public DBOSPortableSerializer() { + this.mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); + // Write dates as ISO-8601 strings for portability + mapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + } + + @Override + public String name() { + return NAME; + } + + @Override + public String stringify(Object value) { + try { + return mapper.writeValueAsString(toPortable(value)); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + @Override + public Object parse(String text) { + if (text == null) { + return null; + } + try { + return mapper.readValue(text, Object.class); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** Serialize workflow arguments in portable format. */ + public String stringifyArgs(Object[] positionalArgs, Map namedArgs) { + JsonWorkflowArgs args = + new JsonWorkflowArgs( + positionalArgs != null ? Arrays.asList(toPortableArray(positionalArgs)) : null, + namedArgs != null ? toPortableMap(namedArgs) : null); + try { + return mapper.writeValueAsString(args); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** Deserialize workflow arguments from portable format. */ + public JsonWorkflowArgs parseArgs(String text) { + if (text == null) { + return null; + } + try { + return mapper.readValue(text, JsonWorkflowArgs.class); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** Serialize an error in portable format. */ + public String stringifyError(Throwable error) { + JsonWorkflowErrorData errorData = + new JsonWorkflowErrorData( + error.getClass().getSimpleName(), + error.getMessage(), + error instanceof PortableWorkflowException pwe ? pwe.getCode() : null, + error instanceof PortableWorkflowException pwe ? pwe.getData() : null); + try { + return mapper.writeValueAsString(errorData); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** Deserialize an error from portable format. */ + public PortableWorkflowException parseError(String text) { + if (text == null) { + return null; + } + try { + JsonWorkflowErrorData errorData = mapper.readValue(text, JsonWorkflowErrorData.class); + return PortableWorkflowException.fromErrorData(errorData); + } catch (JsonProcessingException e) { + throw new JSONUtil.JsonRuntimeException(e); + } + } + + /** + * Convert a value to its portable representation. - Dates become ISO-8601 strings - Other objects + * pass through (Jackson handles them) + */ + @SuppressWarnings("unchecked") + private Object toPortable(Object value) { + if (value == null) { + return null; + } + + // Convert dates to ISO-8601 strings + if (value instanceof Date date) { + return DateTimeFormatter.ISO_INSTANT.format(date.toInstant()); + } + if (value instanceof Instant instant) { + return DateTimeFormatter.ISO_INSTANT.format(instant); + } + if (value instanceof OffsetDateTime odt) { + return DateTimeFormatter.ISO_OFFSET_DATE_TIME.format(odt); + } + if (value instanceof ZonedDateTime zdt) { + return DateTimeFormatter.ISO_ZONED_DATE_TIME.format(zdt); + } + + // Convert arrays recursively + if (value instanceof Object[] array) { + return toPortableArray(array); + } + + // Convert lists recursively + if (value instanceof List list) { + return list.stream().map(this::toPortable).toList(); + } + + // Convert maps recursively + if (value instanceof Map map) { + return toPortableMap((Map) map); + } + + // Errors become error data + if (value instanceof Throwable t) { + return new JsonWorkflowErrorData(t.getClass().getSimpleName(), t.getMessage()); + } + + return value; + } + + private Object[] toPortableArray(Object[] array) { + Object[] result = new Object[array.length]; + for (int i = 0; i < array.length; i++) { + result[i] = toPortable(array[i]); + } + return result; + } + + @SuppressWarnings("unchecked") + private Map toPortableMap(Map map) { + java.util.HashMap result = new java.util.HashMap<>(); + for (Map.Entry entry : map.entrySet()) { + result.put(entry.getKey(), toPortable(entry.getValue())); + } + return result; + } +} diff --git a/transact/src/main/java/dev/dbos/transact/json/DBOSSerializer.java b/transact/src/main/java/dev/dbos/transact/json/DBOSSerializer.java new file mode 100644 index 00000000..61749dcd --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/DBOSSerializer.java @@ -0,0 +1,29 @@ +package dev.dbos.transact.json; + +/** + * Generic serializer interface for DBOS. Implementations must be able to serialize any value to a + * string and deserialize it back. + */ +public interface DBOSSerializer { + /** + * Return a name for the serialization format. This name is stored in the database to identify how + * data was serialized. + */ + String name(); + + /** + * Serialize a value to a string. + * + * @param value The value to serialize + * @return The serialized string representation + */ + String stringify(Object value); + + /** + * Deserialize a string back to a value. + * + * @param text A serialized string (potentially null) + * @return The deserialized value, or null if the input was null + */ + Object parse(String text); +} diff --git a/transact/src/main/java/dev/dbos/transact/json/JsonWorkflowArgs.java b/transact/src/main/java/dev/dbos/transact/json/JsonWorkflowArgs.java new file mode 100644 index 00000000..925f457b --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/JsonWorkflowArgs.java @@ -0,0 +1,19 @@ +package dev.dbos.transact.json; + +import java.util.List; +import java.util.Map; + +/** + * Portable representation of workflow arguments. This format can be serialized/deserialized by any + * language. + */ +public record JsonWorkflowArgs(List positionalArgs, Map namedArgs) { + + public JsonWorkflowArgs() { + this(null, null); + } + + public JsonWorkflowArgs(List positionalArgs) { + this(positionalArgs, null); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/json/JsonWorkflowErrorData.java b/transact/src/main/java/dev/dbos/transact/json/JsonWorkflowErrorData.java new file mode 100644 index 00000000..bc671505 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/JsonWorkflowErrorData.java @@ -0,0 +1,12 @@ +package dev.dbos.transact.json; + +/** + * Portable representation of workflow errors. This format can be serialized/deserialized by any + * language. + */ +public record JsonWorkflowErrorData(String name, String message, Object code, Object data) { + + public JsonWorkflowErrorData(String name, String message) { + this(name, message, null, null); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/json/PortableWorkflowException.java b/transact/src/main/java/dev/dbos/transact/json/PortableWorkflowException.java new file mode 100644 index 00000000..48dbfb20 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/PortableWorkflowException.java @@ -0,0 +1,45 @@ +package dev.dbos.transact.json; + +/** + * Exception that can be serialized and deserialized portably across languages. Used when + * deserializing errors from the portable JSON format. + */ +public class PortableWorkflowException extends RuntimeException { + private final String errorName; + private final Object code; + private final Object data; + + public PortableWorkflowException(String message, String errorName, Object code, Object data) { + super(message); + this.errorName = errorName; + this.code = code; + this.data = data; + } + + public PortableWorkflowException(String message, String errorName) { + this(message, errorName, null, null); + } + + public String getErrorName() { + return errorName; + } + + public Object getCode() { + return code; + } + + public Object getData() { + return data; + } + + /** Create from portable error data. */ + public static PortableWorkflowException fromErrorData(JsonWorkflowErrorData errorData) { + return new PortableWorkflowException( + errorData.message(), errorData.name(), errorData.code(), errorData.data()); + } + + /** Convert to portable error data. */ + public JsonWorkflowErrorData toErrorData() { + return new JsonWorkflowErrorData(errorName, getMessage(), code, data); + } +} diff --git a/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java b/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java new file mode 100644 index 00000000..52947912 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java @@ -0,0 +1,286 @@ +package dev.dbos.transact.json; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Utility class for serialization and deserialization with support for multiple formats. + * + *

This class handles the logic of choosing the appropriate serializer based on the serialization + * format stored in the database. It supports: + * + *

    + *
  • {@code portable_json} - Portable format compatible with any language + *
  • {@code java_serialize} - Native Java format with type information + *
  • Custom serializers registered by the application + *
+ */ +public final class SerializationUtil { + + /** Serialization format for portable JSON (cross-language compatible). */ + public static final String PORTABLE = DBOSPortableSerializer.NAME; + + /** Serialization format for native Java serialization. */ + public static final String NATIVE = DBOSJavaSerializer.NAME; + + private SerializationUtil() {} + + // ============ Value Serialization ============ + + /** + * Serialize a value using the specified format. + * + * @param value the value to serialize + * @param format the serialization format ("portable_json", "java_jackson", or a custom serializer name) + * @param customSerializer optional custom serializer (used if format is not portable/native) + * @return the serialized result containing the serialized string and the serializer name + */ + public static SerializedResult serializeValue( + Object value, String format, DBOSSerializer customSerializer) { + + if (PORTABLE.equals(format)) { + String serialized = DBOSPortableSerializer.INSTANCE.stringify(value); + return new SerializedResult(serialized, DBOSPortableSerializer.NAME); + } + + if (NATIVE.equals(format)) { + String serialized = DBOSJavaSerializer.INSTANCE.stringify(value); + return new SerializedResult(serialized, DBOSJavaSerializer.NAME); + } + + if (format == null) { + // Default behavior: use native serializer but don't store format (backward compatibility) + String serialized = DBOSJavaSerializer.INSTANCE.stringify(value); + return new SerializedResult(serialized, null); + } + + // Custom serializer + DBOSSerializer serializer = + customSerializer != null ? customSerializer : DBOSJavaSerializer.INSTANCE; + String serialized = serializer.stringify(value); + return new SerializedResult(serialized, serializer.name()); + } + + /** + * Deserialize a value using the serialization format stored with it. + * + * @param serializedValue the serialized string + * @param serialization the serialization format name (from DB column) + * @param customSerializer optional custom serializer + * @return the deserialized value + */ + public static Object deserializeValue( + String serializedValue, String serialization, DBOSSerializer customSerializer) { + + if (serializedValue == null) { + return null; + } + + if (DBOSPortableSerializer.NAME.equals(serialization)) { + return DBOSPortableSerializer.INSTANCE.parse(serializedValue); + } + + if (DBOSJavaSerializer.NAME.equals(serialization) || serialization == null) { + return DBOSJavaSerializer.INSTANCE.parse(serializedValue); + } + + // Try custom serializer + if (customSerializer != null && customSerializer.name().equals(serialization)) { + return customSerializer.parse(serializedValue); + } + + // Fallback to native Java serializer for unknown formats + return DBOSJavaSerializer.INSTANCE.parse(serializedValue); + } + + // ============ Arguments Serialization ============ + + /** + * Serialize workflow arguments using the specified format. + * + * @param positionalArgs the positional arguments + * @param namedArgs the named arguments (only supported for portable format) + * @param format the serialization format + * @param customSerializer optional custom serializer + * @return the serialized result + */ + public static SerializedResult serializeArgs( + Object[] positionalArgs, + Map namedArgs, + String format, + DBOSSerializer customSerializer) { + + if (PORTABLE.equals(format)) { + String serialized = DBOSPortableSerializer.INSTANCE.stringifyArgs(positionalArgs, namedArgs); + return new SerializedResult(serialized, DBOSPortableSerializer.NAME); + } + + if (namedArgs != null && !namedArgs.isEmpty()) { + throw new IllegalArgumentException( + "Serialization format '" + format + "' does not support named arguments"); + } + + if (NATIVE.equals(format) || format == null) { + String serialized = DBOSJavaSerializer.INSTANCE.stringifyArray(positionalArgs); + return new SerializedResult(serialized, DBOSJavaSerializer.NAME); + } + + // Custom serializer + DBOSSerializer serializer = + customSerializer != null ? customSerializer : DBOSJavaSerializer.INSTANCE; + String serialized = serializer.stringify(positionalArgs); + return new SerializedResult(serialized, serializer.name()); + } + + /** + * Deserialize workflow arguments (positional only). + * + * @param serializedValue the serialized string + * @param serialization the serialization format name + * @param customSerializer optional custom serializer + * @return the positional arguments array + */ + public static Object[] deserializePositionalArgs( + String serializedValue, String serialization, DBOSSerializer customSerializer) { + + if (serializedValue == null) { + return new Object[0]; + } + + if (DBOSPortableSerializer.NAME.equals(serialization)) { + JsonWorkflowArgs args = DBOSPortableSerializer.INSTANCE.parseArgs(serializedValue); + if (args == null || args.positionalArgs() == null) { + return new Object[0]; + } + return args.positionalArgs().toArray(); + } + + if (DBOSJavaSerializer.NAME.equals(serialization) || serialization == null) { + return DBOSJavaSerializer.INSTANCE.parseArray(serializedValue); + } + + // Try custom serializer + if (customSerializer != null && customSerializer.name().equals(serialization)) { + Object result = customSerializer.parse(serializedValue); + if (result instanceof Object[]) { + return (Object[]) result; + } + if (result instanceof List list) { + return list.toArray(); + } + return new Object[] {result}; + } + + // Fallback + return DBOSJavaSerializer.INSTANCE.parseArray(serializedValue); + } + + // ============ Error Serialization ============ + + /** + * Serialize an error using the specified format. + * + * @param error the error to serialize + * @param format the serialization format + * @param customSerializer optional custom serializer + * @return the serialized result + */ + public static SerializedResult serializeError( + Throwable error, String format, DBOSSerializer customSerializer) { + + if (PORTABLE.equals(format)) { + String serialized = DBOSPortableSerializer.INSTANCE.stringifyError(error); + return new SerializedResult(serialized, DBOSPortableSerializer.NAME); + } + + if (NATIVE.equals(format) || format == null) { + // Use the existing Java error serialization + String serialized = JSONUtil.serializeAppException(error); + return new SerializedResult(serialized, DBOSJavaSerializer.NAME); + } + + // Custom serializer - use native Java format + String serialized = JSONUtil.serializeAppException(error); + DBOSSerializer serializer = + customSerializer != null ? customSerializer : DBOSJavaSerializer.INSTANCE; + return new SerializedResult(serialized, serializer.name()); + } + + /** + * Deserialize an error. + * + * @param serializedValue the serialized string + * @param serialization the serialization format name + * @param customSerializer optional custom serializer + * @return the deserialized throwable + */ + public static Throwable deserializeError( + String serializedValue, String serialization, DBOSSerializer customSerializer) { + + if (serializedValue == null) { + return null; + } + + if (DBOSPortableSerializer.NAME.equals(serialization)) { + return DBOSPortableSerializer.INSTANCE.parseError(serializedValue); + } + + if (DBOSJavaSerializer.NAME.equals(serialization) || serialization == null) { + return JSONUtil.deserializeAppException(serializedValue); + } + + // Try custom or fall back to Java + try { + return JSONUtil.deserializeAppException(serializedValue); + } catch (Exception e) { + // Return a generic exception with the message + return new RuntimeException("Deserialization failed for format: " + serialization, e); + } + } + + /** + * Safely parse a value, returning the raw string if parsing fails. Used for introspection methods + * that may encounter old or undeserializable data. + */ + public static Object safeParse( + String serializedValue, String serialization, DBOSSerializer customSerializer) { + try { + return deserializeValue(serializedValue, serialization, customSerializer); + } catch (Exception e) { + return serializedValue; + } + } + + /** Safely parse arguments, returning the raw string if parsing fails. */ + public static Object safeParseArgs( + String serializedValue, String serialization, DBOSSerializer customSerializer) { + try { + return deserializePositionalArgs(serializedValue, serialization, customSerializer); + } catch (Exception e) { + return serializedValue; + } + } + + /** Safely parse an error, returning a RuntimeException with the raw message if parsing fails. */ + public static Throwable safeParseError( + String serializedValue, String serialization, DBOSSerializer customSerializer) { + try { + return deserializeError(serializedValue, serialization, customSerializer); + } catch (Exception e) { + return new RuntimeException(serializedValue); + } + } + + /** + * Result of a serialization operation, containing both the serialized string and the name of the + * serializer used (to be stored in the DB). + */ + /** Result of serialization, containing the serialized string and the format used. */ + public record SerializedResult(String serializedValue, String serialization) { + public SerializedResult { + Objects.requireNonNull(serializedValue); + // serialization can be null for backward compatibility (default format) + } + } +} diff --git a/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java b/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java index de48f940..f079c024 100644 --- a/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java +++ b/transact/src/main/java/dev/dbos/transact/migrations/MigrationManager.java @@ -228,7 +228,16 @@ static void runDbosMigrations(Connection conn, String schema, List migra public static List getMigrations(String schema) { Objects.requireNonNull(schema); var migrations = - List.of(migration1, migration2, migration3, migration4, migration5, migration6, migration7); + List.of( + migration1, + migration2, + migration3, + migration4, + migration5, + migration6, + migration7, + migration8, + migration9); return migrations.stream().map(m -> m.formatted(schema)).toList(); } @@ -393,4 +402,20 @@ FOREIGN KEY (workflow_uuid) REFERENCES %1$s.workflow_status(workflow_uuid) """ ALTER TABLE %1$s."workflow_status" ADD COLUMN "owner_xid" VARCHAR(40) DEFAULT NULL """; + + static final String migration8 = + """ + ALTER TABLE %1$s."workflow_status" ADD COLUMN "parent_workflow_id" TEXT DEFAULT NULL; + CREATE INDEX "idx_workflow_status_parent_workflow_id" ON %1$s."workflow_status" ("parent_workflow_id"); + """; + + static final String migration9 = + """ + ALTER TABLE %1$s."workflow_status" ADD COLUMN "serialization" TEXT DEFAULT NULL; + ALTER TABLE %1$s."notifications" ADD COLUMN "serialization" TEXT DEFAULT NULL; + ALTER TABLE %1$s."workflow_events" ADD COLUMN "serialization" TEXT DEFAULT NULL; + ALTER TABLE %1$s."workflow_events_history" ADD COLUMN "serialization" TEXT DEFAULT NULL; + ALTER TABLE %1$s."operation_outputs" ADD COLUMN "serialization" TEXT DEFAULT NULL; + ALTER TABLE %1$s."streams" ADD COLUMN "serialization" TEXT DEFAULT NULL; + """; } diff --git a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java index 6cd3ebd7..608d66ad 100644 --- a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java +++ b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java @@ -2,5 +2,5 @@ public interface InternalWorkflowsService { - void sendWorkflow(String destinationId, Object message, String topic); + void sendWorkflow(String destinationId, Object message, String topic, String serialization); } diff --git a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java index 4b898a66..9b2863b8 100644 --- a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java @@ -1,11 +1,21 @@ package dev.dbos.transact.tempworkflows; import dev.dbos.transact.DBOS; +import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Workflow; public class InternalWorkflowsServiceImpl implements InternalWorkflowsService { @Workflow(name = "internalSendWorkflow") - public void sendWorkflow(String destinationId, Object message, String topic) { - DBOS.send(destinationId, message, topic); + public void sendWorkflow(String destinationId, Object message, String topic, String serialization) { + // Convert the format name back to SerializationStrategy for the public API + SerializationStrategy strategy = null; + if (serialization != null) { + if ("portable_json".equals(serialization)) { + strategy = SerializationStrategy.PORTABLE; + } else if ("java_jackson".equals(serialization)) { + strategy = SerializationStrategy.NATIVE; + } + } + DBOS.send(destinationId, message, topic, null, strategy); } } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/SerializationStrategy.java b/transact/src/main/java/dev/dbos/transact/workflow/SerializationStrategy.java new file mode 100644 index 00000000..6145c59c --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/SerializationStrategy.java @@ -0,0 +1,51 @@ +package dev.dbos.transact.workflow; + +import dev.dbos.transact.json.SerializationUtil; + +/** + * Serialization strategy for workflow arguments and messages. + * + *

This enum represents the strategic choice of serialization format at the client level. The + * actual serialization format name used in the database is determined by the strategy: + * + *

    + *
  • {@link #DEFAULT} - Uses the default format for this language (native Java serialization) + *
  • {@link #PORTABLE} - Uses portable JSON format for cross-language compatibility + *
  • {@link #NATIVE} - Explicitly uses the native format for this language + *
+ */ +public enum SerializationStrategy { + /** + * Use the default serialization for this language. For Java, this is the native Java + * serialization format ({@code java_jackson}). + */ + DEFAULT(null), + + /** + * Use portable JSON serialization ({@code portable_json}). This format is compatible across + * languages and should be used when workflows may be initiated or consumed by applications + * written in different languages (e.g., TypeScript, Python). + */ + PORTABLE(SerializationUtil.PORTABLE), + + /** + * Explicitly use the native serialization format for this language. For Java, this is {@code + * java_jackson}. This is equivalent to {@link #DEFAULT} but makes the choice explicit. + */ + NATIVE(SerializationUtil.NATIVE); + + private final String formatName; + + SerializationStrategy(String formatName) { + this.formatName = formatName; + } + + /** + * Get the serialization format name to use in the database. + * + * @return the format name, or null for DEFAULT (which lets the lower layers decide) + */ + public String formatName() { + return formatName; + } +} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/StepInfo.java b/transact/src/main/java/dev/dbos/transact/workflow/StepInfo.java index deb08363..a0af78df 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/StepInfo.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/StepInfo.java @@ -7,4 +7,5 @@ public record StepInfo( ErrorResult error, String childWorkflowId, Long startedAtEpochMs, - Long completedAtEpochMs) {} + Long completedAtEpochMs, + String serialization) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowClassName.java b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowClassName.java new file mode 100644 index 00000000..1a9d6585 --- /dev/null +++ b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowClassName.java @@ -0,0 +1,30 @@ +package dev.dbos.transact.workflow; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to specify a custom class name for workflow registration. This allows workflows to be + * registered with a portable, language-agnostic name instead of the Java class name. + * + *

Example usage: + * + *

{@code
+ * @WorkflowClassName("MyService")
+ * public class MyServiceImpl implements MyService {
+ *     @Workflow
+ *     public String myWorkflow() { ... }
+ * }
+ * }
+ * + *

This workflow would be registered as "MyService//myWorkflow" instead of + * "com.example.MyServiceImpl//myWorkflow". + */ +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.TYPE) +public @interface WorkflowClassName { + /** The custom class name to use for workflow registration. */ + String value(); +} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStatus.java b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStatus.java index a8a47bac..14a9493a 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStatus.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStatus.java @@ -30,7 +30,8 @@ public record WorkflowStatus( String deduplicationId, Integer priority, String queuePartitionKey, - String forkedFrom) { + String forkedFrom, + String serialization) { @com.fasterxml.jackson.annotation.JsonProperty(access = JsonProperty.Access.READ_ONLY) public Instant deadline() { diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/StepResult.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/StepResult.java index 175ed8a8..f3e6f3e4 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/StepResult.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/StepResult.java @@ -6,21 +6,28 @@ public record StepResult( String functionName, String output, String error, - String childWorkflowId) { + String childWorkflowId, + String serialization) { public StepResult(String workflowId, int stepId, String functionName) { - this(workflowId, stepId, functionName, null, null, null); + this(workflowId, stepId, functionName, null, null, null, null); } public StepResult withOutput(String v) { - return new StepResult(workflowId, stepId, functionName, v, error, childWorkflowId); + return new StepResult( + workflowId, stepId, functionName, v, error, childWorkflowId, serialization); } public StepResult withError(String v) { - return new StepResult(workflowId, stepId, functionName, output, v, childWorkflowId); + return new StepResult( + workflowId, stepId, functionName, output, v, childWorkflowId, serialization); } public StepResult withChildWorkflowId(String v) { - return new StepResult(workflowId, stepId, functionName, output, error, v); + return new StepResult(workflowId, stepId, functionName, output, error, v, serialization); + } + + public StepResult withSerialization(String v) { + return new StepResult(workflowId, stepId, functionName, output, error, childWorkflowId, v); } } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java index 65e5e3fd..705e8ae8 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java @@ -26,12 +26,13 @@ public record WorkflowStatusInternal( Long recoveryAttempts, Long startedAt, Long timeoutMs, - Long deadlineEpochMs) { + Long deadlineEpochMs, + String serialization) { public WorkflowStatusInternal() { this( null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null); } public WorkflowStatusInternal(String workflowUUID, WorkflowState state) { @@ -59,6 +60,7 @@ public WorkflowStatusInternal(String workflowUUID, WorkflowState state) { null, null, null, + null, null); } @@ -87,6 +89,7 @@ public static class Builder { private Long startedAt; private Long timeoutMs; private Long deadlineEpochMs; + private String serialization; public Builder workflowId(String workflowId) { this.workflowId = workflowId; @@ -208,6 +211,11 @@ public Builder deadlineEpochMs(Long deadlineEpochMs) { return this; } + public Builder serialization(String serialization) { + this.serialization = serialization; + return this; + } + public WorkflowStatusInternal build() { return new WorkflowStatusInternal( workflowId, @@ -233,7 +241,8 @@ public WorkflowStatusInternal build() { recoveryAttempts, startedAt, timeoutMs, - deadlineEpochMs); + deadlineEpochMs, + serialization); } } diff --git a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java index ed1929cd..ef3ed40d 100644 --- a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java +++ b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java @@ -481,12 +481,13 @@ public void listSteps() throws IOException { List steps = new ArrayList<>(); for (int i = 0; i < 3; i++) { var step = - new StepInfo(i, "step-%d".formatted(i), "output-%d".formatted(i), null, null, null, null); + new StepInfo( + i, "step-%d".formatted(i), "output-%d".formatted(i), null, null, null, null, null); steps.add(step); } - steps.add(new StepInfo(3, "step-3", null, null, "child-wfid-3", null, null)); + steps.add(new StepInfo(3, "step-3", null, null, "child-wfid-3", null, null, null)); var error = new RuntimeException("error-4"); - steps.add(new StepInfo(4, "step-4", null, ErrorResult.of(error), null, null, null)); + steps.add(new StepInfo(4, "step-4", null, ErrorResult.of(error), null, null, null, null)); when(mockDB.listWorkflowSteps(any())).thenReturn(steps); diff --git a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java index 85520668..8a48a786 100644 --- a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java +++ b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java @@ -880,11 +880,11 @@ public void canListSteps() throws Exception { String workflowId = "workflow-id-1"; List steps = new ArrayList(); - steps.add(new StepInfo(0, "function1", null, null, null, null, null)); - steps.add(new StepInfo(1, "function2", null, null, null, null, null)); - steps.add(new StepInfo(2, "function3", null, null, null, null, null)); - steps.add(new StepInfo(3, "function4", null, null, null, null, null)); - steps.add(new StepInfo(4, "function5", null, null, null, null, null)); + steps.add(new StepInfo(0, "function1", null, null, null, null, null, null)); + steps.add(new StepInfo(1, "function2", null, null, null, null, null, null)); + steps.add(new StepInfo(2, "function3", null, null, null, null, null, null)); + steps.add(new StepInfo(3, "function4", null, null, null, null, null, null)); + steps.add(new StepInfo(4, "function5", null, null, null, null, null, null)); when(mockExec.listWorkflowSteps(workflowId)).thenReturn(steps); diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java new file mode 100644 index 00000000..03ae14d3 --- /dev/null +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -0,0 +1,268 @@ +package dev.dbos.transact.json; + +import static org.junit.jupiter.api.Assertions.*; + +import dev.dbos.transact.DBOS; +import dev.dbos.transact.DBOSClient; +import dev.dbos.transact.config.DBOSConfig; +import dev.dbos.transact.database.SystemDatabase; +import dev.dbos.transact.utils.DBUtils; +import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.SerializationStrategy; +import dev.dbos.transact.workflow.WorkflowHandle; + +import dev.dbos.transact.workflow.Workflow; +import dev.dbos.transact.workflow.WorkflowClassName; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.time.Duration; +import java.util.UUID; + +import com.zaxxer.hikari.HikariDataSource; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Tests for portable serialization format. These tests verify that workflows can be triggered via + * direct database inserts using the portable JSON format, simulating cross-language workflow + * initiation. + */ +@org.junit.jupiter.api.Timeout(value = 2, unit = java.util.concurrent.TimeUnit.MINUTES) +public class PortableSerializationTest { + + private static DBOSConfig dbosConfig; + private HikariDataSource dataSource; + + @BeforeAll + static void onetimeSetup() throws Exception { + PortableSerializationTest.dbosConfig = + DBOSConfig.defaultsFromEnv("portablesertest") + .withDatabaseUrl("jdbc:postgresql://localhost:5432/dbos_java_sys"); + } + + @BeforeEach + void beforeEachTest() throws SQLException { + DBUtils.recreateDB(dbosConfig); + dataSource = SystemDatabase.createDataSource(dbosConfig); + DBOS.reinitialize(dbosConfig); + } + + @AfterEach + void afterEachTest() throws Exception { + dataSource.close(); + DBOS.shutdown(); + } + + /** Workflow interface for portable serialization tests. */ + public interface PortableTestService { + // Use long for timeout because portable JSON deserializes numbers as Long/Integer, + // not as Duration objects + String recvWorkflow(String topic, long timeoutMs); + } + + /** Implementation of the portable test workflow. */ + @WorkflowClassName("PortableTestService") + public static class PortableTestServiceImpl implements PortableTestService { + @Workflow(name = "recvWorkflow") + @Override + public String recvWorkflow(String topic, long timeoutMs) { + Object received = DBOS.recv(topic, Duration.ofMillis(timeoutMs)); + return "received:" + received; + } + } + + /** + * Tests that a workflow can be triggered via direct database insert using portable JSON format. + * This simulates the scenario where a workflow is initiated by another language (e.g., + * TypeScript) using the portable serialization format. + */ + @Test + public void testDirectInsertPortable() throws Exception { + // Register queue and workflow + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + + PortableTestService service = + DBOS.registerWorkflows(PortableTestService.class, new PortableTestServiceImpl()); + + DBOS.launch(); + + String workflowId = UUID.randomUUID().toString(); + + // Insert workflow_status directly with portable_json format + // The inputs are in portable format: { "positionalArgs": ["incoming", 30000] } + // where "incoming" is the topic and 30000 is the timeout in ms + try (Connection conn = dataSource.getConnection()) { + String insertWorkflowSql = + """ + INSERT INTO dbos.workflow_status( + workflow_uuid, + name, + class_name, + config_name, + queue_name, + status, + inputs, + created_at, + serialization + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(insertWorkflowSql)) { + stmt.setString(1, workflowId); + stmt.setString(2, "recvWorkflow"); // workflow name (from @Workflow annotation) + stmt.setString(3, "PortableTestService"); // class name alias (from @WorkflowClassName) + stmt.setString(4, ""); // config_name (instance name) - must be empty string, not null + stmt.setString(5, "testq"); // queue name + stmt.setString(6, "ENQUEUED"); // status + // Portable JSON format for inputs: positionalArgs array with topic and timeout + stmt.setString(7, "{\"positionalArgs\":[\"incoming\",30000]}"); // inputs in portable format + stmt.setLong(8, System.currentTimeMillis()); // created_at + stmt.setString(9, "portable_json"); // serialization format + stmt.executeUpdate(); + } + + // Insert notification directly with portable_json format + String insertNotificationSql = + """ + INSERT INTO dbos.notifications( + destination_uuid, + topic, + message, + serialization + ) + VALUES (?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(insertNotificationSql)) { + stmt.setString(1, workflowId); + stmt.setString(2, "incoming"); // topic + stmt.setString( + 3, "\"HelloFromPortable\""); // message in portable JSON format (quoted string) + stmt.setString(4, "portable_json"); // serialization format + stmt.executeUpdate(); + } + } + + // Retrieve the workflow handle and await the result + WorkflowHandle handle = DBOS.retrieveWorkflow(workflowId); + String result = handle.getResult(); + + // Verify the result + assertEquals("received:HelloFromPortable", result); + + // Verify the workflow completed successfully + var status = handle.getStatus(); + assertEquals("SUCCESS", status.status()); + + // Verify the output was written in portable format + var row = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(row); + assertEquals("portable_json", row.serialization()); + // The output should be in portable format (simple quoted string) + assertEquals("\"received:HelloFromPortable\"", row.output()); + } + + /** + * Tests that a workflow can be enqueued using DBOSClient.enqueueWorkflow with portable + * serialization type option. + */ + @Test + public void testClientEnqueueWithPortableSerialization() throws Exception { + // Register queue and workflow + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + + PortableTestService service = + DBOS.registerWorkflows(PortableTestService.class, new PortableTestServiceImpl()); + + DBOS.launch(); + + // Create a DBOSClient + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + // Enqueue workflow using client with portable serialization + var options = + new DBOSClient.EnqueueOptions("PortableTestService", "recvWorkflow", "testq") + .withWorkflowId(workflowId) + .withSerialization(SerializationStrategy.PORTABLE); + + WorkflowHandle handle = + client.enqueueWorkflow(options, new Object[] {"incoming", 30000L}); + + // Send a message using portable serialization + client.send(workflowId, "HelloFromClient", "incoming", null, DBOSClient.SendOptions.portable()); + + // Await the result + String result = handle.getResult(); + + // Verify the result + assertEquals("received:HelloFromClient", result); + + // Verify the workflow completed successfully + var status = handle.getStatus(); + assertEquals("SUCCESS", status.status()); + + // Verify the workflow was stored with portable serialization + var row = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(row); + assertEquals("portable_json", row.serialization()); + } + } + + /** + * Tests that a workflow can be enqueued using DBOSClient.enqueuePortableWorkflow which uses + * portable JSON serialization by default without validation. + */ + @Test + public void testClientEnqueuePortableWorkflow() throws Exception { + // Register queue and workflow + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + + PortableTestService service = + DBOS.registerWorkflows(PortableTestService.class, new PortableTestServiceImpl()); + + DBOS.launch(); + + // Create a DBOSClient + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + // Enqueue workflow using enqueuePortableWorkflow + var options = + new DBOSClient.EnqueueOptions("PortableTestService", "recvWorkflow", "testq") + .withWorkflowId(workflowId); + + // Use enqueuePortableWorkflow which defaults to portable serialization + WorkflowHandle handle = + client.enqueuePortableWorkflow(options, new Object[] {"incoming", 30000L}, null); + + // Send a message using portable serialization + client.send(workflowId, "HelloFromPortableClient", "incoming", null, DBOSClient.SendOptions.portable()); + + // Await the result + String result = handle.getResult(); + + // Verify the result + assertEquals("received:HelloFromPortableClient", result); + + // Verify the workflow completed successfully + var status = handle.getStatus(); + assertEquals("SUCCESS", status.status()); + + // Verify the workflow was stored with portable serialization + var row = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(row); + assertEquals("portable_json", row.serialization()); + // The output should be in portable format + assertEquals("\"received:HelloFromPortableClient\"", row.output()); + } + } +} diff --git a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java index e6d76689..eb4a0108 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java +++ b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java @@ -39,6 +39,7 @@ public class WorkflowStatusBuilder { private Long timeoutMs; private Long deadlineEpochMs; + private String serialization; public WorkflowStatus build() { return new WorkflowStatus( @@ -66,7 +67,8 @@ public WorkflowStatus build() { deduplicationId, priority, partitionKey, - forkedFrom); + forkedFrom, + serialization); } public WorkflowStatusBuilder(String workflowId) { @@ -197,4 +199,9 @@ public WorkflowStatusBuilder deadlineEpochMs(Long deadlineEpochMs) { this.deadlineEpochMs = deadlineEpochMs; return this; } + + public WorkflowStatusBuilder serialization(String serialization) { + this.serialization = serialization; + return this; + } } diff --git a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusRow.java b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusRow.java index 2adf2042..1539bbcf 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusRow.java +++ b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusRow.java @@ -27,7 +27,8 @@ public record WorkflowStatusRow( String inputs, Long startedAtEpochMs, String deduplicationId, - Integer priority) { + Integer priority, + String serialization) { public WorkflowStatusRow(ResultSet rs) throws SQLException { this( @@ -54,6 +55,7 @@ public WorkflowStatusRow(ResultSet rs) throws SQLException { rs.getString("inputs"), rs.getObject("started_at_epoch_ms", Long.class), rs.getString("deduplication_id"), - rs.getObject("priority", Integer.class)); + rs.getObject("priority", Integer.class), + rs.getString("serialization")); } } From 1856f3fe76c6b34f41ce4e112c5c5a037b8831ba Mon Sep 17 00:00:00 2001 From: Chuck B Date: Wed, 4 Feb 2026 15:48:14 -0500 Subject: [PATCH 02/11] Fixes; tests --- .../java/dev/dbos/transact/DBOSClient.java | 28 +- .../dbos/transact/database/WorkflowDAO.java | 12 +- .../dbos/transact/json/SerializationUtil.java | 3 +- .../InternalWorkflowsServiceImpl.java | 3 +- .../json/PortableSerializationTest.java | 331 +++++++++++++++++- .../java/dev/dbos/transact/utils/DBUtils.java | 39 ++- 6 files changed, 385 insertions(+), 31 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 154e8a8e..0e5be1c0 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -14,11 +14,10 @@ import dev.dbos.transact.workflow.WorkflowStatus; import dev.dbos.transact.workflow.internal.WorkflowStatusInternal; -import java.util.Map; - import java.time.Duration; import java.time.Instant; import java.util.List; +import java.util.Map; import java.util.Objects; import java.util.Optional; import java.util.UUID; @@ -391,7 +390,8 @@ public EnqueueOptions( * {@link SerializationStrategy#DEFAULT} for the default behavior) * @return New `EnqueueOptions` with the serialization strategy set */ - public @NonNull EnqueueOptions withSerialization(@Nullable SerializationStrategy serialization) { + public @NonNull EnqueueOptions withSerialization( + @Nullable SerializationStrategy serialization) { return new EnqueueOptions( this.workflowName, this.queueName, @@ -460,15 +460,17 @@ public EnqueueOptions( } /** - * Enqueue a workflow using portable JSON serialization. This method is intended for cross-language - * workflow initiation where the workflow function definition may not be available in Java. Unlike - * {@link #enqueueWorkflow}, this method does not validate function names or arguments. + * Enqueue a workflow using portable JSON serialization. This method is intended for + * cross-language workflow initiation where the workflow function definition may not be available + * in Java. Unlike {@link #enqueueWorkflow}, this method does not validate function names or + * arguments. * * @param Return type of workflow function * @param Exception thrown by workflow function * @param options `DBOSClient.EnqueueOptions` for enqueuing the workflow * @param positionalArgs Positional arguments to pass to the workflow function - * @param namedArgs Optional named arguments (for workflows that support them, e.g., Python kwargs) + * @param namedArgs Optional named arguments (for workflows that support them, e.g., Python + * kwargs) * @return WorkflowHandle for retrieving workflow ID, status, and results */ public @NonNull WorkflowHandle enqueuePortableWorkflow( @@ -482,10 +484,7 @@ public EnqueueOptions( // Serialize arguments in portable format SerializationUtil.SerializedResult serializedArgs = SerializationUtil.serializeArgs( - positionalArgs, - namedArgs, - SerializationUtil.PORTABLE, - null); + positionalArgs, namedArgs, SerializationUtil.PORTABLE, null); // Create workflow status directly with portable serialization var statusBuilder = @@ -516,9 +515,7 @@ public EnqueueOptions( return new WorkflowHandleClient<>(workflowId); } - /** - * Options for sending a message. - */ + /** Options for sending a message. */ public record SendOptions(@Nullable SerializationStrategy serialization) { /** Create SendOptions with default serialization. */ public static SendOptions defaults() { @@ -580,7 +577,8 @@ public void send( var status = WorkflowStatusInternal.builder(workflowId, WorkflowState.SUCCESS) .name("temp_workflow-send-client") - .serialization(serializationFormat != null ? serializationFormat : SerializationUtil.NATIVE) + .serialization( + serializationFormat != null ? serializationFormat : SerializationUtil.NATIVE) .build(); systemDatabase.initWorkflowStatus(status, null, false, false); systemDatabase.send(status.workflowId(), 0, destinationId, message, topic, serializationFormat); diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java index e6e846df..9838ac0a 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java @@ -864,8 +864,8 @@ private static void copyOperationOutputs( String stepOutputsSql = """ INSERT INTO %1$s.operation_outputs - (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms) - SELECT ? as workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms + (workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization) + SELECT ? as workflow_uuid, function_id, output, error, function_name, child_workflow_id, started_at_epoch_ms, completed_at_epoch_ms, serialization FROM %1$s.operation_outputs WHERE workflow_uuid = ? AND function_id < ? """ @@ -882,8 +882,8 @@ private static void copyOperationOutputs( var eventHistorySql = """ INSERT INTO %1$s.workflow_events_history - (workflow_uuid, function_id, key, value) - SELECT ? as workflow_uuid, function_id, key, value + (workflow_uuid, function_id, key, value, serialization) + SELECT ? as workflow_uuid, function_id, key, value, serialization FROM %1$s.workflow_events_history WHERE workflow_uuid = ? AND function_id < ? """ @@ -900,8 +900,8 @@ private static void copyOperationOutputs( var eventSql = """ INSERT INTO %1$s.workflow_events - (workflow_uuid, key, value) - SELECT ?, weh1.key, weh1.value + (workflow_uuid, key, value, serialization) + SELECT ?, weh1.key, weh1.value, weh1.serialization FROM %1$s.workflow_events_history weh1 WHERE weh1.workflow_uuid = ? AND weh1.function_id = ( diff --git a/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java b/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java index 52947912..3cd37741 100644 --- a/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java +++ b/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java @@ -32,7 +32,8 @@ private SerializationUtil() {} * Serialize a value using the specified format. * * @param value the value to serialize - * @param format the serialization format ("portable_json", "java_jackson", or a custom serializer name) + * @param format the serialization format ("portable_json", "java_jackson", or a custom serializer + * name) * @param customSerializer optional custom serializer (used if format is not portable/native) * @return the serialized result containing the serialized string and the serializer name */ diff --git a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java index 9b2863b8..73f38d8f 100644 --- a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java @@ -6,7 +6,8 @@ public class InternalWorkflowsServiceImpl implements InternalWorkflowsService { @Workflow(name = "internalSendWorkflow") - public void sendWorkflow(String destinationId, Object message, String topic, String serialization) { + public void sendWorkflow( + String destinationId, Object message, String topic, String serialization) { // Convert the format name back to SerializationStrategy for the public API SerializationStrategy strategy = null; if (serialization != null) { diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java index 03ae14d3..dc2a932b 100644 --- a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -9,10 +9,9 @@ import dev.dbos.transact.utils.DBUtils; import dev.dbos.transact.workflow.Queue; import dev.dbos.transact.workflow.SerializationStrategy; -import dev.dbos.transact.workflow.WorkflowHandle; - import dev.dbos.transact.workflow.Workflow; import dev.dbos.transact.workflow.WorkflowClassName; +import dev.dbos.transact.workflow.WorkflowHandle; import java.sql.Connection; import java.sql.PreparedStatement; @@ -21,6 +20,7 @@ import java.util.UUID; import com.zaxxer.hikari.HikariDataSource; +import dev.dbos.transact.StartWorkflowOptions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -197,7 +197,8 @@ public void testClientEnqueueWithPortableSerialization() throws Exception { client.enqueueWorkflow(options, new Object[] {"incoming", 30000L}); // Send a message using portable serialization - client.send(workflowId, "HelloFromClient", "incoming", null, DBOSClient.SendOptions.portable()); + client.send( + workflowId, "HelloFromClient", "incoming", null, DBOSClient.SendOptions.portable()); // Await the result String result = handle.getResult(); @@ -245,7 +246,12 @@ public void testClientEnqueuePortableWorkflow() throws Exception { client.enqueuePortableWorkflow(options, new Object[] {"incoming", 30000L}, null); // Send a message using portable serialization - client.send(workflowId, "HelloFromPortableClient", "incoming", null, DBOSClient.SendOptions.portable()); + client.send( + workflowId, + "HelloFromPortableClient", + "incoming", + null, + DBOSClient.SendOptions.portable()); // Await the result String result = handle.getResult(); @@ -265,4 +271,321 @@ public void testClientEnqueuePortableWorkflow() throws Exception { assertEquals("\"received:HelloFromPortableClient\"", row.output()); } } + + /** Workflow interface for testing setEvent and send with explicit serialization. */ + public interface ExplicitSerService { + String eventWorkflow(); + void senderWorkflow(String targetId); + } + + /** Implementation that sets events with different serialization types. */ + @WorkflowClassName("ExplicitSerService") + public static class ExplicitSerServiceImpl implements ExplicitSerService { + @Workflow(name = "eventWorkflow") + @Override + public String eventWorkflow() { + // Set events with different serialization types + DBOS.setEvent("defaultEvent", "defaultValue"); + DBOS.setEvent("nativeEvent", "nativeValue", SerializationStrategy.NATIVE); + DBOS.setEvent("portableEvent", "portableValue", SerializationStrategy.PORTABLE); + return "done"; + } + + @Workflow(name = "senderWorkflow") + @Override + public void senderWorkflow(String targetId) { + // Send messages with different serialization types + DBOS.send(targetId, "defaultMsg", "defaultTopic"); + DBOS.send(targetId, "nativeMsg", "nativeTopic", null, SerializationStrategy.NATIVE); + DBOS.send(targetId, "portableMsg", "portableTopic", null, SerializationStrategy.PORTABLE); + } + } + + /** Workflow that throws an error for testing portable error serialization. */ + public interface ErrorService { + void errorWorkflow(); + } + + @WorkflowClassName("ErrorService") + public static class ErrorServiceImpl implements ErrorService { + @Workflow(name = "errorWorkflow") + @Override + public void errorWorkflow() { + throw new RuntimeException("Workflow failed!"); + } + } + + /** + * Tests that DBOS.setEvent() with explicit SerializationStrategy correctly stores the + * serialization format in the database. + */ + @Test + public void testSetEventWithExplicitSerialization() throws Exception { + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServiceImpl()); + + DBOS.launch(); + + // Use DBOSClient to enqueue and run the workflow + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + var options = + new DBOSClient.EnqueueOptions("ExplicitSerService", "eventWorkflow", "testq") + .withWorkflowId(workflowId); + + WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); + String result = handle.getResult(); + assertEquals("done", result); + + // Check workflow's serialization - client-enqueued workflows get java_jackson by default + var wfRow = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(wfRow); + assertEquals("java_jackson", wfRow.serialization()); + + // Verify the events in the database have correct serialization + var events = DBUtils.getWorkflowEvents(dataSource, workflowId); + assertEquals(3, events.size()); + + // Find each event and verify serialization + var defaultEvent = events.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var nativeEvent = events.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); + var portableEvent = events.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + + assertTrue(defaultEvent.isPresent()); + assertTrue(nativeEvent.isPresent()); + assertTrue(portableEvent.isPresent()); + + // Default setEvent inherits workflow's serialization (java_jackson in this case) + assertEquals("java_jackson", defaultEvent.get().serialization()); + // Native should have java_jackson (explicitly set) + assertEquals("java_jackson", nativeEvent.get().serialization()); + // Portable should have portable_json (explicitly set) + assertEquals("portable_json", portableEvent.get().serialization()); + + // Also verify the event history + var eventHistory = DBUtils.getWorkflowEventHistory(dataSource, workflowId); + assertEquals(3, eventHistory.size()); + + var defaultHist = eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var nativeHist = eventHistory.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); + var portableHist = eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + + assertTrue(defaultHist.isPresent()); + assertTrue(nativeHist.isPresent()); + assertTrue(portableHist.isPresent()); + + assertEquals("java_jackson", defaultHist.get().serialization()); + assertEquals("java_jackson", nativeHist.get().serialization()); + assertEquals("portable_json", portableHist.get().serialization()); + } + } + + /** + * Tests that DBOS.send() with explicit SerializationStrategy correctly stores the serialization + * format in the notifications table. + */ + @Test + public void testSendWithExplicitSerialization() throws Exception { + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServiceImpl()); + + DBOS.launch(); + + // Create a target workflow to receive messages + String targetId = UUID.randomUUID().toString(); + + // Insert a dummy workflow to be the target (so FK constraint is satisfied) + try (Connection conn = dataSource.getConnection()) { + String insertSql = + """ + INSERT INTO dbos.workflow_status(workflow_uuid, name, class_name, config_name, status, created_at) + VALUES (?, 'dummy', 'Dummy', '', 'PENDING', ?) + """; + try (PreparedStatement stmt = conn.prepareStatement(insertSql)) { + stmt.setString(1, targetId); + stmt.setLong(2, System.currentTimeMillis()); + stmt.executeUpdate(); + } + } + + // Use DBOSClient to enqueue and run the sender workflow + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + var options = + new DBOSClient.EnqueueOptions("ExplicitSerService", "senderWorkflow", "testq") + .withWorkflowId(workflowId); + + WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {targetId}); + handle.getResult(); + + // Verify the notifications in the database have correct serialization + var notifications = DBUtils.getNotifications(dataSource, targetId); + assertEquals(3, notifications.size()); + + var defaultNotif = notifications.stream().filter(n -> n.topic().equals("defaultTopic")).findFirst(); + var nativeNotif = notifications.stream().filter(n -> n.topic().equals("nativeTopic")).findFirst(); + var portableNotif = notifications.stream().filter(n -> n.topic().equals("portableTopic")).findFirst(); + + assertTrue(defaultNotif.isPresent()); + assertTrue(nativeNotif.isPresent()); + assertTrue(portableNotif.isPresent()); + + // Default should have null serialization (backward compatible) + assertNull(defaultNotif.get().serialization()); + // Native should have java_jackson + assertEquals("java_jackson", nativeNotif.get().serialization()); + // Portable should have portable_json + assertEquals("portable_json", portableNotif.get().serialization()); + + // Also verify the message format + // Portable format wraps strings in quotes + assertEquals("\"portableMsg\"", portableNotif.get().message()); + } + } + + /** + * Tests that a portable workflow (started via portable enqueue) uses portable serialization by + * default for setEvent when no explicit serialization is specified. + */ + @Test + public void testPortableWorkflowDefaultSerialization() throws Exception { + // Workflow that sets an event without explicit serialization + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + + // Register a simple workflow that sets an event + DBOS.registerWorkflows(EventSetterService.class, new EventSetterServiceImpl()); + + DBOS.launch(); + + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + // Enqueue with portable serialization + var options = + new DBOSClient.EnqueueOptions("EventSetterService", "setEventWorkflow", "testq") + .withWorkflowId(workflowId) + .withSerialization(SerializationStrategy.PORTABLE); + + WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); + + // Wait for completion + String result = handle.getResult(); + assertEquals("eventSet", result); + + // Verify the workflow used portable serialization + var row = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(row); + assertEquals("portable_json", row.serialization()); + + // Verify the event inherited portable serialization (since it was set without explicit type) + var events = DBUtils.getWorkflowEvents(dataSource, workflowId); + assertEquals(1, events.size()); + assertEquals("myKey", events.get(0).key()); + // Event should inherit workflow's portable serialization + assertEquals("portable_json", events.get(0).serialization()); + } + } + + /** Simple workflow interface for event setting tests. */ + public interface EventSetterService { + String setEventWorkflow(); + } + + @WorkflowClassName("EventSetterService") + public static class EventSetterServiceImpl implements EventSetterService { + @Workflow(name = "setEventWorkflow") + @Override + public String setEventWorkflow() { + // Set event without explicit serialization - should inherit from workflow context + DBOS.setEvent("myKey", "myValue"); + return "eventSet"; + } + } + + /** + * Tests that errors thrown from portable workflows are stored in portable JSON format. + */ + @Test + public void testPortableWorkflowErrorSerialization() throws Exception { + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + + DBOS.registerWorkflows(ErrorService.class, new ErrorServiceImpl()); + + DBOS.launch(); + + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + // Enqueue with portable serialization + var options = + new DBOSClient.EnqueueOptions("ErrorService", "errorWorkflow", "testq") + .withWorkflowId(workflowId) + .withSerialization(SerializationStrategy.PORTABLE); + + WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); + + // Wait for completion - should throw + try { + handle.getResult(); + fail("Expected exception to be thrown"); + } catch (Exception e) { + // Expected + assertTrue(e.getMessage().contains("Workflow failed!")); + } + + // Verify the workflow stored error in portable format + var row = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(row); + assertEquals("portable_json", row.serialization()); + assertEquals("ERROR", row.status()); + + // Verify error is in portable JSON format + assertNotNull(row.error()); + // Portable error format: {"name":"...", "message":"..."} + assertTrue(row.error().contains("\"name\"")); + assertTrue(row.error().contains("\"message\"")); + assertTrue(row.error().contains("Workflow failed!")); + } + } + + /** + * Tests that DBOSClient.getEvent can retrieve events set with different serialization formats. + */ + @Test + public void testClientGetEvent() throws Exception { + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServiceImpl()); + + DBOS.launch(); + + // Use DBOSClient to enqueue and run the workflow that sets events + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + var options = + new DBOSClient.EnqueueOptions("ExplicitSerService", "eventWorkflow", "testq") + .withWorkflowId(workflowId); + + WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); + String result = handle.getResult(); + assertEquals("done", result); + + // Get events with different serializations + Object defaultVal = client.getEvent(workflowId, "defaultEvent", Duration.ofSeconds(5)); + Object nativeVal = client.getEvent(workflowId, "nativeEvent", Duration.ofSeconds(5)); + Object portableVal = client.getEvent(workflowId, "portableEvent", Duration.ofSeconds(5)); + + // All should be retrievable regardless of serialization format + assertEquals("defaultValue", defaultVal); + assertEquals("nativeValue", nativeVal); + assertEquals("portableValue", portableVal); + } + } } diff --git a/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java b/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java index ae9c2983..f1a0c7a9 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java +++ b/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java @@ -275,7 +275,7 @@ public static List getStepRows( } } - public record Event(String key, String value) {} + public record Event(String key, String value, String serialization) {} public static List getWorkflowEvents(DataSource ds, String workflowId) throws SQLException { @@ -296,14 +296,15 @@ public static List getWorkflowEvents(DataSource ds, String workflowId, St while (rs.next()) { var key = rs.getString("key"); var value = rs.getString("value"); - rows.add(new Event(key, value)); + var serialization = rs.getString("serialization"); + rows.add(new Event(key, value, serialization)); } return rows; } } - public record EventHistory(int stepId, String key, String value) {} + public record EventHistory(int stepId, String key, String value, String serialization) {} public static List getWorkflowEventHistory(DataSource ds, String workflowId) throws SQLException { @@ -325,13 +326,43 @@ public static List getWorkflowEventHistory( var stepId = rs.getInt("function_id"); var key = rs.getString("key"); var value = rs.getString("value"); - rows.add(new EventHistory(stepId, key, value)); + var serialization = rs.getString("serialization"); + rows.add(new EventHistory(stepId, key, value, serialization)); } return rows; } } + public record Notification(String destinationUuid, String topic, String message, String serialization) {} + + public static List getNotifications(DataSource ds, String destinationUuid) + throws SQLException { + return getNotifications(ds, destinationUuid, null); + } + + public static List getNotifications( + DataSource ds, String destinationUuid, String schema) throws SQLException { + schema = SystemDatabase.sanitizeSchema(schema); + var sql = + "SELECT * FROM %s.notifications WHERE destination_uuid = ? ORDER BY created_at_epoch_ms" + .formatted(schema); + try (var conn = ds.getConnection(); + var stmt = conn.prepareStatement(sql)) { + stmt.setString(1, destinationUuid); + try (var rs = stmt.executeQuery()) { + List rows = new ArrayList<>(); + while (rs.next()) { + var topic = rs.getString("topic"); + var message = rs.getString("message"); + var serialization = rs.getString("serialization"); + rows.add(new Notification(destinationUuid, topic, message, serialization)); + } + return rows; + } + } + } + public static boolean queueEntriesCleanedUp(DataSource ds) throws SQLException { return queueEntriesCleanedUp(ds, null); } From 64a663a3cb782668950ae79e372a71459c552179 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Wed, 4 Feb 2026 15:56:57 -0500 Subject: [PATCH 03/11] Spotless or something --- .../json/PortableSerializationTest.java | 21 +++++++++++-------- .../java/dev/dbos/transact/utils/DBUtils.java | 3 ++- 2 files changed, 14 insertions(+), 10 deletions(-) diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java index dc2a932b..ec9388b9 100644 --- a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -20,7 +20,6 @@ import java.util.UUID; import com.zaxxer.hikari.HikariDataSource; -import dev.dbos.transact.StartWorkflowOptions; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -275,6 +274,7 @@ public void testClientEnqueuePortableWorkflow() throws Exception { /** Workflow interface for testing setEvent and send with explicit serialization. */ public interface ExplicitSerService { String eventWorkflow(); + void senderWorkflow(String targetId); } @@ -368,9 +368,11 @@ public void testSetEventWithExplicitSerialization() throws Exception { var eventHistory = DBUtils.getWorkflowEventHistory(dataSource, workflowId); assertEquals(3, eventHistory.size()); - var defaultHist = eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var defaultHist = + eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); var nativeHist = eventHistory.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); - var portableHist = eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + var portableHist = + eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); assertTrue(defaultHist.isPresent()); assertTrue(nativeHist.isPresent()); @@ -426,9 +428,12 @@ public void testSendWithExplicitSerialization() throws Exception { var notifications = DBUtils.getNotifications(dataSource, targetId); assertEquals(3, notifications.size()); - var defaultNotif = notifications.stream().filter(n -> n.topic().equals("defaultTopic")).findFirst(); - var nativeNotif = notifications.stream().filter(n -> n.topic().equals("nativeTopic")).findFirst(); - var portableNotif = notifications.stream().filter(n -> n.topic().equals("portableTopic")).findFirst(); + var defaultNotif = + notifications.stream().filter(n -> n.topic().equals("defaultTopic")).findFirst(); + var nativeNotif = + notifications.stream().filter(n -> n.topic().equals("nativeTopic")).findFirst(); + var portableNotif = + notifications.stream().filter(n -> n.topic().equals("portableTopic")).findFirst(); assertTrue(defaultNotif.isPresent()); assertTrue(nativeNotif.isPresent()); @@ -507,9 +512,7 @@ public String setEventWorkflow() { } } - /** - * Tests that errors thrown from portable workflows are stored in portable JSON format. - */ + /** Tests that errors thrown from portable workflows are stored in portable JSON format. */ @Test public void testPortableWorkflowErrorSerialization() throws Exception { Queue testQueue = new Queue("testq"); diff --git a/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java b/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java index f1a0c7a9..89e0d8b4 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java +++ b/transact/src/test/java/dev/dbos/transact/utils/DBUtils.java @@ -334,7 +334,8 @@ public static List getWorkflowEventHistory( } } - public record Notification(String destinationUuid, String topic, String message, String serialization) {} + public record Notification( + String destinationUuid, String topic, String message, String serialization) {} public static List getNotifications(DataSource ds, String destinationUuid) throws SQLException { From e7ffd04e5f37904999c627ff25a5182126d45894 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Wed, 11 Feb 2026 10:45:34 -0500 Subject: [PATCH 04/11] Fix merge conflicts and errors --- .../dbos/transact/database/WorkflowDAO.java | 9 ++++--- .../dbos/transact/workflow/ErrorResult.java | 26 +++++++++---------- .../dbos/transact/admin/AdminServerTest.java | 11 +++++++- .../transact/conductor/ConductorTest.java | 5 ++-- .../transact/utils/WorkflowStatusBuilder.java | 2 +- 5 files changed, 33 insertions(+), 20 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java index 0bb2fc11..9ca9fa66 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java @@ -330,7 +330,7 @@ WorkflowStatus getWorkflowStatus(Connection conn, String workflowId) throws SQLE SELECT workflow_uuid, status, forked_from, name, class_name, config_name, - inputs, output, error, + inputs, output, error, serialization, queue_name, deduplication_id, priority, queue_partition_key, executor_id, application_version, application_id, authenticated_user, assumed_role, authenticated_roles, @@ -375,7 +375,7 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException executor_id, application_version, application_id, authenticated_user, assumed_role, authenticated_roles, created_at, updated_at, recovery_attempts, started_at_epoch_ms, - workflow_timeout_ms, workflow_deadline_epoch_ms, serialization + workflow_timeout_ms, workflow_deadline_epoch_ms """); var loadInput = input.loadInput() == null || input.loadInput(); @@ -386,6 +386,9 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException if (loadOutput) { sqlBuilder.append(", output, error"); } + if (loadInput || loadOutput) { + sqlBuilder.append(", serialization"); + } sqlBuilder.append(" FROM %s.workflow_status ".formatted(this.schema)); @@ -517,7 +520,7 @@ private static WorkflowStatus resultsToWorkflowStatus( String serializedInput = loadInput ? rs.getString("inputs") : null; String serializedOutput = loadOutput ? rs.getString("output") : null; String serializedError = loadOutput ? rs.getString("error") : null; - ErrorResult err = ErrorResult.deserialize(serializedError); + var err = ErrorResult.deserialize(serializedError, rs.getString("serialization"), null); WorkflowStatus info = new WorkflowStatus( workflow_uuid, diff --git a/transact/src/main/java/dev/dbos/transact/workflow/ErrorResult.java b/transact/src/main/java/dev/dbos/transact/workflow/ErrorResult.java index de57c32b..6b14cfa2 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/ErrorResult.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/ErrorResult.java @@ -1,26 +1,26 @@ package dev.dbos.transact.workflow; +import dev.dbos.transact.json.DBOSSerializer; import dev.dbos.transact.json.JSONUtil; public record ErrorResult( String className, String message, String serializedError, Throwable throwable) { - public static ErrorResult fromThrowable(Throwable error) { - if (error != null) { - var serializedError = JSONUtil.serializeAppException(error); - return deserialize(serializedError); - } else { + public static ErrorResult fromThrowable( + Throwable error, String serialization, DBOSSerializer serializer) { + if (error == null) { return null; } + var serializedError = JSONUtil.serializeAppException(error); + return deserialize(serializedError, serialization, serializer); } - public static ErrorResult deserialize(String serializedError) { - if (serializedError != null) { - var wrapper = JSONUtil.deserializeAppExceptionWrapper(serializedError); - Throwable throwable = JSONUtil.deserializeAppException(serializedError); - return new ErrorResult(wrapper.type, wrapper.message, serializedError, throwable); - } else { - return null; - } + public static ErrorResult deserialize( + String serializedError, String serialization, DBOSSerializer serializer) { + if (serializedError == null) return null; + + var wrapper = JSONUtil.deserializeAppExceptionWrapper(serializedError); + Throwable throwable = JSONUtil.deserializeAppException(serializedError); + return new ErrorResult(wrapper.type, wrapper.message, serializedError, throwable); } } diff --git a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java index 4afeb66c..6c6e09b6 100644 --- a/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java +++ b/transact/src/test/java/dev/dbos/transact/admin/AdminServerTest.java @@ -487,7 +487,16 @@ public void listSteps() throws IOException { } steps.add(new StepInfo(3, "step-3", null, null, "child-wfid-3", null, null, null)); var error = new RuntimeException("error-4"); - steps.add(new StepInfo(4, "step-4", null, ErrorResult.fromThrowable(error), null, null, null, null)); + steps.add( + new StepInfo( + 4, + "step-4", + null, + ErrorResult.fromThrowable(error, null, null), + null, + null, + null, + null)); when(mockDB.listWorkflowSteps(any())).thenReturn(steps); diff --git a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java index 028730b8..bd4c4b51 100644 --- a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java +++ b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java @@ -357,7 +357,7 @@ public void onWebsocketMessage(WebSocket conn, Framedata frame) { for (int j = 0; j < 1024; j++) { builder.append(characters.charAt(random.nextInt(characters.length()))); } - steps.add(new StepInfo(i, "function" + i, builder.toString(), null, null, null, null)); + steps.add(new StepInfo(i, "function" + i, builder.toString(), null, null, null, null, null)); } when(mockExec.listWorkflowSteps("large-wf")).thenReturn(steps); @@ -1570,7 +1570,8 @@ private static ExportedWorkflow createTestExportedWorkflow(int index) { null, null, currentTime + (i * 1000), - currentTime + ((i + 1) * 1000))); + currentTime + ((i + 1) * 1000), + null)); } int eventCount = (int) (Math.random() * 8) + 2; diff --git a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java index ff11271c..4074ebaa 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java +++ b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java @@ -111,7 +111,7 @@ public WorkflowStatusBuilder output(Object output) { } public WorkflowStatusBuilder error(Throwable error) { - this.error = ErrorResult.fromThrowable(error); + this.error = ErrorResult.fromThrowable(error, this.serialization, null); return this; } From 1bf2ec470933262ecf57a0e5073dd01454e74ba4 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Wed, 11 Feb 2026 11:27:42 -0500 Subject: [PATCH 05/11] Fix refactored code --- .../dev/dbos/transact/database/WorkflowDAO.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java index 9ca9fa66..97a2d43c 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java @@ -3,6 +3,7 @@ import dev.dbos.transact.Constants; import dev.dbos.transact.exceptions.*; import dev.dbos.transact.internal.DebugTriggers; +import dev.dbos.transact.json.DBOSSerializer; import dev.dbos.transact.json.JSONUtil; import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ErrorResult; @@ -345,7 +346,7 @@ WorkflowStatus getWorkflowStatus(Connection conn, String workflowId) throws SQLE stmt.setString(1, workflowId); try (var rs = stmt.executeQuery()) { if (rs.next()) { - return resultsToWorkflowStatus(rs, true, true); + return resultsToWorkflowStatus(rs, true, true, null); } } } @@ -504,7 +505,7 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException try (ResultSet rs = pstmt.executeQuery()) { while (rs.next()) { - WorkflowStatus info = resultsToWorkflowStatus(rs, loadInput, loadOutput); + WorkflowStatus info = resultsToWorkflowStatus(rs, loadInput, loadOutput, null); workflows.add(info); } } @@ -514,13 +515,15 @@ List listWorkflows(ListWorkflowsInput input) throws SQLException } private static WorkflowStatus resultsToWorkflowStatus( - ResultSet rs, boolean loadInput, boolean loadOutput) throws SQLException { + ResultSet rs, boolean loadInput, boolean loadOutput, DBOSSerializer serializer) + throws SQLException { var workflow_uuid = rs.getString("workflow_uuid"); String authenticatedRolesJson = rs.getString("authenticated_roles"); String serializedInput = loadInput ? rs.getString("inputs") : null; String serializedOutput = loadOutput ? rs.getString("output") : null; String serializedError = loadOutput ? rs.getString("error") : null; - var err = ErrorResult.deserialize(serializedError, rs.getString("serialization"), null); + String serialization = loadInput || loadOutput ? rs.getString("serialization") : null; + var err = ErrorResult.deserialize(serializedError, serialization, null); WorkflowStatus info = new WorkflowStatus( workflow_uuid, @@ -533,8 +536,8 @@ private static WorkflowStatus resultsToWorkflowStatus( (authenticatedRolesJson != null) ? (String[]) JSONUtil.deserializeToArray(authenticatedRolesJson) : null, - (serializedInput != null) ? JSONUtil.deserializeToArray(serializedInput) : null, - (serializedOutput != null) ? JSONUtil.deserializeToArray(serializedOutput)[0] : null, + SerializationUtil.deserializePositionalArgs(serializedInput, serialization, serializer), + SerializationUtil.deserializeValue(serializedOutput, serialization, serializer), err, rs.getString("executor_id"), rs.getObject("created_at", Long.class), From 89408aebbb30f33615dddb78418f355b95c2757f Mon Sep 17 00:00:00 2001 From: Chuck B Date: Wed, 11 Feb 2026 12:01:34 -0500 Subject: [PATCH 06/11] Fix conflicts --- .../workflow/internal/WorkflowStatusInternal.java | 3 ++- .../dev/dbos/transact/utils/WorkflowStatusBuilder.java | 10 ++-------- 2 files changed, 4 insertions(+), 9 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java b/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java index e32a21e6..017e22e4 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/internal/WorkflowStatusInternal.java @@ -33,7 +33,7 @@ public record WorkflowStatusInternal( public WorkflowStatusInternal() { this( null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, - null, null, null, null, null, null, null, null, null, null); + null, null, null, null, null, null, null, null, null, null, null); } public WorkflowStatusInternal(String workflowUUID, WorkflowState state) { @@ -62,6 +62,7 @@ public WorkflowStatusInternal(String workflowUUID, WorkflowState state) { null, null, null, + null, null); } diff --git a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java index c8e7f7b1..dffe970d 100644 --- a/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java +++ b/transact/src/test/java/dev/dbos/transact/utils/WorkflowStatusBuilder.java @@ -39,11 +39,8 @@ public class WorkflowStatusBuilder { private Long timeoutMs; private Long deadlineEpochMs; private String forkedFrom; -<<<<<<< HEAD - private String serialization; -======= private String parentWorkflowId; ->>>>>>> origin/main + private String serialization; public WorkflowStatus build() { return new WorkflowStatus( @@ -72,11 +69,8 @@ public WorkflowStatus build() { priority, partitionKey, forkedFrom, -<<<<<<< HEAD + parentWorkflowId, serialization); -======= - parentWorkflowId); ->>>>>>> origin/main } public WorkflowStatusBuilder(String workflowId) { From a1e506391a99598e4c9e5fd2da973848597c3e35 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Thu, 12 Feb 2026 09:33:02 -0500 Subject: [PATCH 07/11] Add serialization to WF import/export --- .../transact/database/SystemDatabase.java | 41 +++++++++++-------- .../dbos/transact/workflow/WorkflowEvent.java | 2 +- .../workflow/WorkflowEventHistory.java | 2 +- .../transact/workflow/WorkflowStream.java | 3 +- .../transact/conductor/ConductorTest.java | 7 ++-- 5 files changed, 33 insertions(+), 22 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 21ddc932..153fea66 100644 --- a/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java +++ b/transact/src/main/java/dev/dbos/transact/database/SystemDatabase.java @@ -684,7 +684,7 @@ List getWorkflowChildrenInternal(String workflowId) throws SQLException List listWorkflowEvents(Connection conn, String workflowId) throws SQLException { var sql = """ - SELECT key, value + SELECT key, value, serialization FROM %s.workflow_events WHERE workflow_uuid = ? """ @@ -697,7 +697,8 @@ List listWorkflowEvents(Connection conn, String workflowId) throw while (rs.next()) { var key = rs.getString("key"); var value = rs.getString("value"); - events.add(new WorkflowEvent(key, value)); + var serialization = rs.getString("serialization"); + events.add(new WorkflowEvent(key, value, serialization)); } } } @@ -708,7 +709,7 @@ List listWorkflowEventHistory(Connection conn, String work throws SQLException { var sql = """ - SELECT key, value, function_id + SELECT key, value, function_id, serialization FROM %s.workflow_events_history WHERE workflow_uuid = ? """ @@ -722,7 +723,8 @@ List listWorkflowEventHistory(Connection conn, String work var key = rs.getString("key"); var value = rs.getString("value"); var stepId = rs.getInt("function_id"); - history.add(new WorkflowEventHistory(key, value, stepId)); + var serialization = rs.getString("serialization"); + history.add(new WorkflowEventHistory(key, value, stepId, serialization)); } } } @@ -732,7 +734,7 @@ List listWorkflowEventHistory(Connection conn, String work List listWorkflowStreams(Connection conn, String workflowId) throws SQLException { var sql = """ - SELECT key, value, "offset", function_id + SELECT key, value, "offset", function_id, serialization FROM %s.streams WHERE workflow_uuid = ? """ @@ -747,7 +749,8 @@ List listWorkflowStreams(Connection conn, String workflowId) thr var value = rs.getString("value"); var offset = rs.getInt("offset"); var stepId = rs.getInt("function_id"); - streams.add(new WorkflowStream(key, value, offset, stepId)); + var serialization = rs.getString("serialization"); + streams.add(new WorkflowStream(key, value, offset, stepId, serialization)); } } } @@ -792,9 +795,9 @@ public void importWorkflow(List workflows) { created_at, updated_at, started_at_epoch_ms, queue_name, deduplication_id, priority, queue_partition_key, workflow_timeout_ms, workflow_deadline_epoch_ms, - recovery_attempts, forked_from, parent_workflow_id + recovery_attempts, forked_from, parent_workflow_id, serialization ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) """ .formatted(this.schema); @@ -804,9 +807,10 @@ public void importWorkflow(List workflows) { INSERT INTO %s.operation_outputs ( workflow_uuid, function_id, function_name, output, error, child_workflow_id, - started_at_epoch_ms, completed_at_epoch_ms + started_at_epoch_ms, completed_at_epoch_ms, + serialization ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ? ) """ .formatted(this.schema); @@ -814,9 +818,9 @@ public void importWorkflow(List workflows) { var eventSQL = """ INSERT INTO %s.workflow_events ( - workflow_uuid, key, value + workflow_uuid, key, value, serialization ) VALUES ( - ?, ?, ? + ?, ?, ?, ? ) """ .formatted(this.schema); @@ -824,9 +828,9 @@ public void importWorkflow(List workflows) { var eventHistorySQL = """ INSERT INTO %s.workflow_events_history ( - workflow_uuid, key, value, function_id + workflow_uuid, key, value, function_id, serialization ) VALUES ( - ?, ?, ?, ? + ?, ?, ?, ?, ? ) """ .formatted(this.schema); @@ -834,9 +838,9 @@ public void importWorkflow(List workflows) { var streamsSQL = """ INSERT INTO %s.streams ( - workflow_uuid, key, value, function_id, offset + workflow_uuid, key, value, function_id, offset, serialization ) VALUES ( - ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ? ) """ .formatted(this.schema); @@ -884,6 +888,7 @@ public void importWorkflow(List workflows) { stmt.setObject(24, status.recoveryAttempts()); stmt.setString(25, status.forkedFrom()); stmt.setString(26, status.parentWorkflowId()); + stmt.setString(27, status.serialization()); stmt.executeUpdate(); } @@ -899,6 +904,7 @@ public void importWorkflow(List workflows) { stmt.setString(6, step.childWorkflowId()); stmt.setObject(7, step.startedAtEpochMs()); stmt.setObject(8, step.completedAtEpochMs()); + stmt.setString(9, step.serialization()); stmt.executeUpdate(); } @@ -909,6 +915,7 @@ public void importWorkflow(List workflows) { stmt.setString(1, status.workflowId()); stmt.setString(2, event.key()); stmt.setString(3, event.value()); + stmt.setString(4, event.serialization()); stmt.executeUpdate(); } @@ -920,6 +927,7 @@ public void importWorkflow(List workflows) { stmt.setString(2, history.key()); stmt.setString(3, history.value()); stmt.setInt(4, history.stepId()); + stmt.setString(5, history.serialization()); stmt.executeUpdate(); } @@ -932,6 +940,7 @@ public void importWorkflow(List workflows) { stmt.setString(3, stream.value()); stmt.setInt(4, stream.stepId()); stmt.setInt(5, stream.offset()); + stmt.setString(6, stream.serialization()); stmt.executeUpdate(); } diff --git a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEvent.java b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEvent.java index ef0ddd40..47443446 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEvent.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEvent.java @@ -1,3 +1,3 @@ package dev.dbos.transact.workflow; -public record WorkflowEvent(String key, String value) {} +public record WorkflowEvent(String key, String value, String serialization) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEventHistory.java b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEventHistory.java index e5d3895c..90646b36 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEventHistory.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowEventHistory.java @@ -1,3 +1,3 @@ package dev.dbos.transact.workflow; -public record WorkflowEventHistory(String key, String value, int stepId) {} +public record WorkflowEventHistory(String key, String value, int stepId, String serialization) {} diff --git a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStream.java b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStream.java index aed8d105..301209d6 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStream.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/WorkflowStream.java @@ -1,3 +1,4 @@ package dev.dbos.transact.workflow; -public record WorkflowStream(String key, String value, int offset, int stepId) {} +public record WorkflowStream( + String key, String value, int offset, int stepId, String serialization) {} diff --git a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java index bd4c4b51..a087edd3 100644 --- a/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java +++ b/transact/src/test/java/dev/dbos/transact/conductor/ConductorTest.java @@ -1577,7 +1577,7 @@ private static ExportedWorkflow createTestExportedWorkflow(int index) { int eventCount = (int) (Math.random() * 8) + 2; List events = new ArrayList<>(); for (int i = 0; i < eventCount; i++) { - events.add(new WorkflowEvent(prefix + "event" + (i + 1), prefix + "value" + (i + 1))); + events.add(new WorkflowEvent(prefix + "event" + (i + 1), prefix + "value" + (i + 1), null)); } int historyCount = (int) (Math.random() * 8) + 2; @@ -1587,7 +1587,7 @@ private static ExportedWorkflow createTestExportedWorkflow(int index) { String eventKey = eventCount > 0 ? prefix + "event" + ((i % eventCount) + 1) : prefix + "event" + (i + 1); eventHistory.add( - new WorkflowEventHistory(eventKey, prefix + "historyvalue" + (i + 1), stepId)); + new WorkflowEventHistory(eventKey, prefix + "historyvalue" + (i + 1), stepId, null)); } int streamCount = (int) (Math.random() * 8) + 2; @@ -1596,7 +1596,8 @@ private static ExportedWorkflow createTestExportedWorkflow(int index) { int stepId = i % Math.max(1, stepCount); // Distribute across available steps int offset = i % 3; // Vary offset between 0-2 String streamKey = prefix + "stream" + ((i % 3) + 1); // Use 3 different stream keys - streams.add(new WorkflowStream(streamKey, prefix + "streamvalue" + (i + 1), offset, stepId)); + streams.add( + new WorkflowStream(streamKey, prefix + "streamvalue" + (i + 1), offset, stepId, null)); } return new ExportedWorkflow(status, steps, events, eventHistory, streams); From 574a483be304a3b3a892bbaac0b7a0e87903e0ec Mon Sep 17 00:00:00 2001 From: Chuck B Date: Thu, 12 Feb 2026 10:55:39 -0500 Subject: [PATCH 08/11] Some portability fixes --- .../src/main/java/dev/dbos/transact/DBOS.java | 15 +++++------ .../dbos/transact/context/DBOSContext.java | 13 +++++++--- .../dbos/transact/database/WorkflowDAO.java | 16 +++++++++--- .../transact/database/WorkflowInitResult.java | 6 ++++- .../dbos/transact/execution/DBOSExecutor.java | 25 ++++++++++++------ .../dbos/transact/json/SerializationUtil.java | 26 ++++++++----------- .../dev/dbos/transact/workflow/Workflow.java | 2 ++ .../json/PortableSerializationTest.java | 2 +- 8 files changed, 64 insertions(+), 41 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 9cc4eef7..2cdefc1c 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -521,7 +521,7 @@ public static T getResult(@NonNull String workflowId) t * @return the serialization format name (e.g., "portable_json", "java_jackson"), or null if not * in a workflow context or using default serialization */ - public static @Nullable String getSerialization() { + public static @Nullable SerializationStrategy getSerialization() { var ctx = DBOSContextHolder.get(); return ctx != null ? ctx.getSerialization() : null; } @@ -557,7 +557,7 @@ public static void send( @NonNull String topic, @Nullable String idempotencyKey, @Nullable SerializationStrategy serialization) { - String serializationFormat = serialization != null ? serialization.formatName() : null; + if (serialization == null) serialization = SerializationStrategy.DEFAULT; executor("send") .send( destinationId, @@ -565,7 +565,7 @@ public static void send( topic, instance().internalWorkflowsService, idempotencyKey, - serializationFormat); + serialization); } /** @@ -611,13 +611,10 @@ public static void setEvent(@NonNull String key, @NonNull Object value) { public static void setEvent( @NonNull String key, @NonNull Object value, @Nullable SerializationStrategy serialization) { // If no explicit serialization specified, use the workflow context's serialization - String serializationFormat; - if (serialization != null) { - serializationFormat = serialization.formatName(); - } else { - serializationFormat = getSerialization(); + if (serialization == null) { + serialization = getSerialization(); } - executor("setEvent").setEvent(key, value, serializationFormat); + executor("setEvent").setEvent(key, value, serialization); } /** 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 1549e616..58699d08 100644 --- a/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java +++ b/transact/src/main/java/dev/dbos/transact/context/DBOSContext.java @@ -1,6 +1,7 @@ package dev.dbos.transact.context; import dev.dbos.transact.StartWorkflowOptions; +import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.Timeout; import java.time.Duration; @@ -21,7 +22,7 @@ public class DBOSContext { private final WorkflowInfo parent; private final Duration timeout; private final Instant deadline; - private final String serialization; + private SerializationStrategy serialization; // private StepStatus stepStatus; @@ -31,7 +32,7 @@ public DBOSContext() { parent = null; timeout = null; deadline = null; - serialization = null; + serialization = SerializationStrategy.DEFAULT; } public DBOSContext(String workflowId, WorkflowInfo parent, Duration timeout, Instant deadline) { @@ -43,7 +44,7 @@ public DBOSContext( WorkflowInfo parent, Duration timeout, Instant deadline, - String serialization) { + SerializationStrategy serialization) { this.workflowId = workflowId; this.functionId = 0; this.parent = parent; @@ -135,10 +136,14 @@ public Instant getDeadline() { return deadline; } - public String getSerialization() { + public SerializationStrategy getSerialization() { return serialization; } + public void setSerializationStrategy(SerializationStrategy strat) { + this.serialization = strat; + } + public static String workflowId() { var ctx = DBOSContextHolder.get(); return ctx == null ? null : ctx.workflowId; diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java index f704da6c..1105f6f9 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java @@ -93,7 +93,11 @@ WorkflowInitResult initWorkflowStatus( throw new DBOSMaxRecoveryAttemptsExceededException(initStatus.workflowId(), maxRetries); } return new WorkflowInitResult( - initStatus.workflowId(), resRow.status(), resRow.deadlineEpochMs(), false); + initStatus.workflowId(), + resRow.status(), + resRow.deadlineEpochMs(), + false, + resRow.serialization()); } // Upsert above already set executor assignment and incremented the recovery attempt @@ -122,7 +126,11 @@ WorkflowInitResult initWorkflowStatus( } return new WorkflowInitResult( - initStatus.workflowId(), resRow.status(), resRow.deadlineEpochMs(), true); + initStatus.workflowId(), + resRow.status(), + resRow.deadlineEpochMs(), + true, + resRow.serialization()); } finally { if (shouldCommit) { @@ -144,6 +152,7 @@ static record InsertWorkflowResult( String queueName, Long timeoutMs, Long deadlineEpochMs, + String serialization, String ownerXid) {} /** @@ -187,7 +196,7 @@ ON CONFLICT (workflow_uuid) THEN workflow_status.executor_id ELSE EXCLUDED.executor_id END - RETURNING recovery_attempts, status, name, class_name, config_name, queue_name, workflow_timeout_ms, workflow_deadline_epoch_ms, owner_xid + RETURNING recovery_attempts, status, name, class_name, config_name, queue_name, workflow_timeout_ms, workflow_deadline_epoch_ms, owner_xid, serialization """ .formatted(this.schema); @@ -242,6 +251,7 @@ ON CONFLICT (workflow_uuid) rs.getString("queue_name"), rs.getObject("workflow_timeout_ms", Long.class), rs.getObject("workflow_deadline_epoch_ms", Long.class), + rs.getString("serialization"), rs.getString("owner_xid")); return result; diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowInitResult.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowInitResult.java index 77286278..dea26bed 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowInitResult.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowInitResult.java @@ -3,7 +3,11 @@ import java.util.Objects; public record WorkflowInitResult( - String workflowId, String status, Long deadlineEpochMS, boolean shouldExecuteOnThisExecutor) { + String workflowId, + String status, + Long deadlineEpochMS, + boolean shouldExecuteOnThisExecutor, + String serialization) { public Long deadlineEpochMS() { return Objects.requireNonNullElse(deadlineEpochMS, 0L); } 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 e46f39c9..e248b10b 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -25,6 +25,7 @@ import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; import dev.dbos.transact.workflow.Queue; +import dev.dbos.transact.workflow.SerializationStrategy; import dev.dbos.transact.workflow.StepInfo; import dev.dbos.transact.workflow.StepOptions; import dev.dbos.transact.workflow.Timeout; @@ -698,7 +699,7 @@ public void send( String topic, InternalWorkflowsService internalWorkflowsService, String idempotencyKey, - String serialization) { + SerializationStrategy serialization) { DBOSContext ctx = DBOSContextHolder.get(); if (ctx.isInStep()) { @@ -708,7 +709,8 @@ public void send( var sendWfid = idempotencyKey == null ? null : "%s-%s".formatted(destinationId, idempotencyKey); try (var wfid = new WorkflowOptions(sendWfid).setContext()) { - internalWorkflowsService.sendWorkflow(destinationId, message, topic, serialization); + internalWorkflowsService.sendWorkflow( + destinationId, message, topic, serialization.formatName()); } return; } @@ -720,7 +722,12 @@ public void send( int stepFunctionId = ctx.getAndIncrementFunctionId(); systemDatabase.send( - ctx.getWorkflowId(), stepFunctionId, destinationId, message, topic, serialization); + ctx.getWorkflowId(), + stepFunctionId, + destinationId, + message, + topic, + serialization.formatName()); } /** @@ -745,7 +752,7 @@ public Object recv(String topic, Duration timeout) { ctx.getWorkflowId(), stepFunctionId, timeoutFunctionId, topic, timeout); } - public void setEvent(String key, Object value, String serialization) { + public void setEvent(String key, Object value, SerializationStrategy serialization) { logger.debug("Received setEvent for key {}", key); DBOSContext ctx = DBOSContextHolder.get(); @@ -755,7 +762,8 @@ public void setEvent(String key, Object value, String serialization) { var asStep = !ctx.isInStep(); var stepId = ctx.isInStep() ? ctx.getCurrentFunctionId() : ctx.getAndIncrementFunctionId(); - systemDatabase.setEvent(ctx.getWorkflowId(), stepId, key, value, asStep, serialization); + systemDatabase.setEvent( + ctx.getWorkflowId(), stepId, key, value, asStep, serialization.formatName()); } public Object getEvent(String workflowId, String key, Duration timeout) { @@ -1201,8 +1209,7 @@ private WorkflowHandle executeWorkflow( if (workflowId.isEmpty()) { throw new IllegalArgumentException("workflowId cannot be empty"); } - WorkflowInitResult initResult = null; - initResult = + WorkflowInitResult initResult = preInvokeWorkflow( systemDatabase, workflow.name(), @@ -1250,7 +1257,9 @@ private WorkflowHandle executeWorkflow( parent, options.timeoutDuration(), options.deadline(), - options.serialization())); + SerializationUtil.PORTABLE.equals(initResult.serialization()) + ? SerializationStrategy.PORTABLE + : SerializationStrategy.DEFAULT)); if (Thread.currentThread().isInterrupted()) { logger.debug("executeWorkflow task interrupted before workflow.invoke"); return null; diff --git a/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java b/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java index 3cd37741..bfce0132 100644 --- a/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java +++ b/transact/src/main/java/dev/dbos/transact/json/SerializationUtil.java @@ -32,8 +32,7 @@ private SerializationUtil() {} * Serialize a value using the specified format. * * @param value the value to serialize - * @param format the serialization format ("portable_json", "java_jackson", or a custom serializer - * name) + * @param format the serialization format ("portable_json", "java_jackson", custom name, null) * @param customSerializer optional custom serializer (used if format is not portable/native) * @return the serialized result containing the serialized string and the serializer name */ @@ -50,15 +49,12 @@ public static SerializedResult serializeValue( return new SerializedResult(serialized, DBOSJavaSerializer.NAME); } - if (format == null) { - // Default behavior: use native serializer but don't store format (backward compatibility) - String serialized = DBOSJavaSerializer.INSTANCE.stringify(value); - return new SerializedResult(serialized, null); - } - - // Custom serializer + // Default / custom DBOSSerializer serializer = customSerializer != null ? customSerializer : DBOSJavaSerializer.INSTANCE; + if (format != null && !serializer.name().equals(format)) { + throw new IllegalArgumentException("Serializer is not available"); + } String serialized = serializer.stringify(value); return new SerializedResult(serialized, serializer.name()); } @@ -82,17 +78,17 @@ public static Object deserializeValue( return DBOSPortableSerializer.INSTANCE.parse(serializedValue); } - if (DBOSJavaSerializer.NAME.equals(serialization) || serialization == null) { + if (DBOSJavaSerializer.NAME.equals(serialization)) { return DBOSJavaSerializer.INSTANCE.parse(serializedValue); } - // Try custom serializer - if (customSerializer != null && customSerializer.name().equals(serialization)) { - return customSerializer.parse(serializedValue); + DBOSSerializer serializer = customSerializer; + if (serializer == null) serializer = DBOSJavaSerializer.INSTANCE; + if (serialization != null && !serializer.name().equals(serialization)) { + throw new IllegalArgumentException("Serialization is not available"); } - // Fallback to native Java serializer for unknown formats - return DBOSJavaSerializer.INSTANCE.parse(serializedValue); + return serializer.parse(serializedValue); } // ============ Arguments Serialization ============ diff --git a/transact/src/main/java/dev/dbos/transact/workflow/Workflow.java b/transact/src/main/java/dev/dbos/transact/workflow/Workflow.java index 746f6e46..eaf7f43d 100644 --- a/transact/src/main/java/dev/dbos/transact/workflow/Workflow.java +++ b/transact/src/main/java/dev/dbos/transact/workflow/Workflow.java @@ -11,4 +11,6 @@ String name() default ""; int maxRecoveryAttempts() default -1; + + SerializationStrategy serializationStrategy() default SerializationStrategy.DEFAULT; } diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java index ec9388b9..b1e6dc22 100644 --- a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -440,7 +440,7 @@ public void testSendWithExplicitSerialization() throws Exception { assertTrue(portableNotif.isPresent()); // Default should have null serialization (backward compatible) - assertNull(defaultNotif.get().serialization()); + assertEquals("java_jackson", defaultNotif.get().serialization()); // Native should have java_jackson assertEquals("java_jackson", nativeNotif.get().serialization()); // Portable should have portable_json From 7c73aee167323dcf3fb393438f1c4a817b7dec60 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Fri, 13 Feb 2026 06:45:42 -0500 Subject: [PATCH 09/11] Allow NULL for instance name --- .../java/dev/dbos/transact/DBOSClient.java | 12 ++--- .../dbos/transact/database/WorkflowDAO.java | 4 +- .../json/PortableSerializationTest.java | 45 +++++++++---------- 3 files changed, 26 insertions(+), 35 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOSClient.java b/transact/src/main/java/dev/dbos/transact/DBOSClient.java index 0e5be1c0..a364e7f9 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOSClient.java +++ b/transact/src/main/java/dev/dbos/transact/DBOSClient.java @@ -3,6 +3,7 @@ import dev.dbos.transact.database.Result; import dev.dbos.transact.database.SystemDatabase; import dev.dbos.transact.execution.DBOSExecutor; +import dev.dbos.transact.json.PortableWorkflowException; import dev.dbos.transact.json.SerializationUtil; import dev.dbos.transact.workflow.ForkOptions; import dev.dbos.transact.workflow.ListWorkflowsInput; @@ -462,18 +463,16 @@ public EnqueueOptions( /** * Enqueue a workflow using portable JSON serialization. This method is intended for * cross-language workflow initiation where the workflow function definition may not be available - * in Java. Unlike {@link #enqueueWorkflow}, this method does not validate function names or - * arguments. + * in Java. * * @param Return type of workflow function - * @param Exception thrown by workflow function * @param options `DBOSClient.EnqueueOptions` for enqueuing the workflow * @param positionalArgs Positional arguments to pass to the workflow function * @param namedArgs Optional named arguments (for workflows that support them, e.g., Python * kwargs) * @return WorkflowHandle for retrieving workflow ID, status, and results */ - public @NonNull WorkflowHandle enqueuePortableWorkflow( + public @NonNull WorkflowHandle enqueuePortableWorkflow( @NonNull EnqueueOptions options, @Nullable Object[] positionalArgs, @Nullable Map namedArgs) { @@ -526,11 +525,6 @@ public static SendOptions defaults() { public static SendOptions portable() { return new SendOptions(SerializationStrategy.PORTABLE); } - - /** Create SendOptions with native Java serialization. */ - public static SendOptions nativeSerialization() { - return new SendOptions(SerializationStrategy.NATIVE); - } } /** diff --git a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java index 1105f6f9..d5f14d75 100644 --- a/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java +++ b/transact/src/main/java/dev/dbos/transact/database/WorkflowDAO.java @@ -546,8 +546,8 @@ private static WorkflowStatus resultsToWorkflowStatus( workflow_uuid, rs.getString("status"), rs.getString("name"), - rs.getString("class_name"), - rs.getString("config_name"), + Objects.requireNonNullElse(rs.getString("class_name"), ""), + Objects.requireNonNullElse(rs.getString("config_name"), ""), rs.getString("authenticated_user"), rs.getString("assumed_role"), (authenticatedRolesJson != null) diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java index b1e6dc22..5513885b 100644 --- a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -58,8 +58,6 @@ void afterEachTest() throws Exception { /** Workflow interface for portable serialization tests. */ public interface PortableTestService { - // Use long for timeout because portable JSON deserializes numbers as Long/Integer, - // not as Duration objects String recvWorkflow(String topic, long timeoutMs); } @@ -76,8 +74,7 @@ public String recvWorkflow(String topic, long timeoutMs) { /** * Tests that a workflow can be triggered via direct database insert using portable JSON format. - * This simulates the scenario where a workflow is initiated by another language (e.g., - * TypeScript) using the portable serialization format. + * This simulates the scenario where a workflow is initiated by another language. */ @Test public void testDirectInsertPortable() throws Exception { @@ -116,7 +113,7 @@ public void testDirectInsertPortable() throws Exception { stmt.setString(1, workflowId); stmt.setString(2, "recvWorkflow"); // workflow name (from @Workflow annotation) stmt.setString(3, "PortableTestService"); // class name alias (from @WorkflowClassName) - stmt.setString(4, ""); // config_name (instance name) - must be empty string, not null + stmt.setString(4, null); stmt.setString(5, "testq"); // queue name stmt.setString(6, "ENQUEUED"); // status // Portable JSON format for inputs: positionalArgs array with topic and timeout @@ -241,8 +238,8 @@ public void testClientEnqueuePortableWorkflow() throws Exception { .withWorkflowId(workflowId); // Use enqueuePortableWorkflow which defaults to portable serialization - WorkflowHandle handle = - client.enqueuePortableWorkflow(options, new Object[] {"incoming", 30000L}, null); + var handle = + client.enqueuePortableWorkflow(options, new Object[] {"incoming", 30000L}, null); // Send a message using portable serialization client.send( @@ -358,7 +355,7 @@ public void testSetEventWithExplicitSerialization() throws Exception { assertTrue(portableEvent.isPresent()); // Default setEvent inherits workflow's serialization (java_jackson in this case) - assertEquals("java_jackson", defaultEvent.get().serialization()); + assertEquals("java_jackson", defaultEvent.get().serialization()); // TODO this is incorrect // Native should have java_jackson (explicitly set) assertEquals("java_jackson", nativeEvent.get().serialization()); // Portable should have portable_json (explicitly set) @@ -452,6 +449,22 @@ public void testSendWithExplicitSerialization() throws Exception { } } + /** Simple workflow interface for event setting tests. */ + public interface EventSetterService { + String setEventWorkflow(); + } + + @WorkflowClassName("EventSetterService") + public static class EventSetterServiceImpl implements EventSetterService { + @Workflow(name = "setEventWorkflow") + @Override + public String setEventWorkflow() { + // Set event without explicit serialization - should inherit from workflow context + DBOS.setEvent("myKey", "myValue"); + return "eventSet"; + } + } + /** * Tests that a portable workflow (started via portable enqueue) uses portable serialization by * default for setEvent when no explicit serialization is specified. @@ -496,22 +509,6 @@ public void testPortableWorkflowDefaultSerialization() throws Exception { } } - /** Simple workflow interface for event setting tests. */ - public interface EventSetterService { - String setEventWorkflow(); - } - - @WorkflowClassName("EventSetterService") - public static class EventSetterServiceImpl implements EventSetterService { - @Workflow(name = "setEventWorkflow") - @Override - public String setEventWorkflow() { - // Set event without explicit serialization - should inherit from workflow context - DBOS.setEvent("myKey", "myValue"); - return "eventSet"; - } - } - /** Tests that errors thrown from portable workflows are stored in portable JSON format. */ @Test public void testPortableWorkflowErrorSerialization() throws Exception { From a09db3e2e280e1f42627b252e76cc5744d8100d5 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Fri, 13 Feb 2026 10:10:36 -0500 Subject: [PATCH 10/11] Wiring up context --- .../src/main/java/dev/dbos/transact/DBOS.java | 8 +- .../dbos/transact/execution/DBOSExecutor.java | 14 ++- .../execution/RegisteredWorkflow.java | 5 +- .../transact/internal/WorkflowRegistry.java | 12 ++- .../InternalWorkflowsService.java | 5 +- .../InternalWorkflowsServiceImpl.java | 14 +-- .../json/PortableSerializationTest.java | 93 ++++++++++++++++++- 7 files changed, 132 insertions(+), 19 deletions(-) diff --git a/transact/src/main/java/dev/dbos/transact/DBOS.java b/transact/src/main/java/dev/dbos/transact/DBOS.java index 8cc58b7d..3de0a453 100644 --- a/transact/src/main/java/dev/dbos/transact/DBOS.java +++ b/transact/src/main/java/dev/dbos/transact/DBOS.java @@ -145,7 +145,13 @@ private void registerClassWorkflows( String name = wfTag.name().isEmpty() ? method.getName() : wfTag.name(); workflowRegistry.register( - className, name, target, instanceName, method, wfTag.maxRecoveryAttempts()); + className, + name, + target, + instanceName, + method, + wfTag.maxRecoveryAttempts(), + wfTag.serializationStrategy()); return name; } 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 7cf1ea3c..962e1252 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -735,8 +735,7 @@ public void send( var sendWfid = idempotencyKey == null ? null : "%s-%s".formatted(destinationId, idempotencyKey); try (var wfid = new WorkflowOptions(sendWfid).setContext()) { - internalWorkflowsService.sendWorkflow( - destinationId, message, topic, serialization.formatName()); + internalWorkflowsService.sendWorkflow(destinationId, message, topic, serialization); } return; } @@ -786,6 +785,14 @@ public void setEvent(String key, Object value, SerializationStrategy serializati throw new IllegalStateException("DBOS.setEvent() must be called from a workflow."); } + if (serialization == null || serialization.equals(SerializationStrategy.DEFAULT)) { + if (ctx.getSerialization() != null) { + serialization = ctx.getSerialization(); + } else { + serialization = SerializationStrategy.DEFAULT; + } + } + var asStep = !ctx.isInStep(); var stepId = ctx.isInStep() ? ctx.getCurrentFunctionId() : ctx.getAndIncrementFunctionId(); systemDatabase.setEvent( @@ -1152,6 +1159,9 @@ public WorkflowHandle invokeWorkflow( } var options = new ExecutionOptions(workflowId, timeout, deadline); + if (workflow.serializationStrategy() != null) { + options = options.withSerialization(workflow.serializationStrategy().formatName()); + } return executeWorkflow(workflow, args, options, parent); } diff --git a/transact/src/main/java/dev/dbos/transact/execution/RegisteredWorkflow.java b/transact/src/main/java/dev/dbos/transact/execution/RegisteredWorkflow.java index a3cca21c..c18df692 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/RegisteredWorkflow.java +++ b/transact/src/main/java/dev/dbos/transact/execution/RegisteredWorkflow.java @@ -1,5 +1,7 @@ package dev.dbos.transact.execution; +import dev.dbos.transact.workflow.SerializationStrategy; + import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.util.Objects; @@ -10,7 +12,8 @@ public record RegisteredWorkflow( String instanceName, Object target, Method workflowMethod, - int maxRecoveryAttempts) { + int maxRecoveryAttempts, + SerializationStrategy serializationStrategy) { public RegisteredWorkflow { Objects.requireNonNull(name, "workflow name must not be null"); 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 cf433007..61df42d1 100644 --- a/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java +++ b/transact/src/main/java/dev/dbos/transact/internal/WorkflowRegistry.java @@ -3,6 +3,7 @@ import dev.dbos.transact.execution.RegisteredWorkflow; import dev.dbos.transact.execution.RegisteredWorkflowInstance; import dev.dbos.transact.execution.SchedulerService; +import dev.dbos.transact.workflow.SerializationStrategy; import java.lang.reflect.Method; import java.util.Map; @@ -29,12 +30,19 @@ public void register( Object target, String instanceName, Method method, - int maxRecoveryAttempts) { + int maxRecoveryAttempts, + SerializationStrategy serializationStrategy) { var fqName = RegisteredWorkflow.fullyQualifiedName(className, instanceName, workflowName); var regWorkflow = new RegisteredWorkflow( - workflowName, className, instanceName, target, method, maxRecoveryAttempts); + workflowName, + className, + instanceName, + target, + method, + maxRecoveryAttempts, + serializationStrategy); SchedulerService.validateScheduledWorkflow(regWorkflow); var previous = wfRegistry.putIfAbsent(fqName, regWorkflow); diff --git a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java index 608d66ad..c3e9e07b 100644 --- a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java +++ b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsService.java @@ -1,6 +1,9 @@ package dev.dbos.transact.tempworkflows; +import dev.dbos.transact.workflow.SerializationStrategy; + public interface InternalWorkflowsService { - void sendWorkflow(String destinationId, Object message, String topic, String serialization); + void sendWorkflow( + String destinationId, Object message, String topic, SerializationStrategy serialization); } diff --git a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java index 73f38d8f..4b49f184 100644 --- a/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java +++ b/transact/src/main/java/dev/dbos/transact/tempworkflows/InternalWorkflowsServiceImpl.java @@ -7,16 +7,8 @@ public class InternalWorkflowsServiceImpl implements InternalWorkflowsService { @Workflow(name = "internalSendWorkflow") public void sendWorkflow( - String destinationId, Object message, String topic, String serialization) { - // Convert the format name back to SerializationStrategy for the public API - SerializationStrategy strategy = null; - if (serialization != null) { - if ("portable_json".equals(serialization)) { - strategy = SerializationStrategy.PORTABLE; - } else if ("java_jackson".equals(serialization)) { - strategy = SerializationStrategy.NATIVE; - } - } - DBOS.send(destinationId, message, topic, null, strategy); + String destinationId, Object message, String topic, SerializationStrategy serialization) { + + DBOS.send(destinationId, message, topic, null, serialization); } } diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java index 5513885b..231c2f0d 100644 --- a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -298,6 +298,29 @@ public void senderWorkflow(String targetId) { } } + /** Implementation that sets events with different serialization types. */ + @WorkflowClassName("ExplicitSerServicePortable") + public static class ExplicitSerServicePortableImpl implements ExplicitSerService { + @Workflow(name = "eventWorkflow", serializationStrategy = SerializationStrategy.PORTABLE) + @Override + public String eventWorkflow() { + // Set events with different serialization types + DBOS.setEvent("defaultEvent", "defaultValue"); + DBOS.setEvent("nativeEvent", "nativeValue", SerializationStrategy.NATIVE); + DBOS.setEvent("portableEvent", "portableValue", SerializationStrategy.PORTABLE); + return "done"; + } + + @Workflow(name = "senderWorkflow") + @Override + public void senderWorkflow(String targetId) { + // Send messages with different serialization types + DBOS.send(targetId, "defaultMsg", "defaultTopic"); + DBOS.send(targetId, "nativeMsg", "nativeTopic", null, SerializationStrategy.NATIVE); + DBOS.send(targetId, "portableMsg", "portableTopic", null, SerializationStrategy.PORTABLE); + } + } + /** Workflow that throws an error for testing portable error serialization. */ public interface ErrorService { void errorWorkflow(); @@ -355,7 +378,7 @@ public void testSetEventWithExplicitSerialization() throws Exception { assertTrue(portableEvent.isPresent()); // Default setEvent inherits workflow's serialization (java_jackson in this case) - assertEquals("java_jackson", defaultEvent.get().serialization()); // TODO this is incorrect + assertEquals("java_jackson", defaultEvent.get().serialization()); // Native should have java_jackson (explicitly set) assertEquals("java_jackson", nativeEvent.get().serialization()); // Portable should have portable_json (explicitly set) @@ -381,6 +404,74 @@ public void testSetEventWithExplicitSerialization() throws Exception { } } + /** + * Tests that DBOS.setEvent() with explicit SerializationStrategy correctly stores the + * serialization format in the database. + */ + @Test + public void testSetEventWithPortableSerialization() throws Exception { + Queue testQueue = new Queue("testq"); + DBOS.registerQueue(testQueue); + DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServicePortableImpl()); + + DBOS.launch(); + + // Use DBOSClient to enqueue and run the workflow + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + + var options = + new DBOSClient.EnqueueOptions("ExplicitSerServicePortable", "eventWorkflow", "testq") + .withWorkflowId(workflowId) + .withSerialization(SerializationStrategy.PORTABLE); + + WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); + String result = handle.getResult(); + assertEquals("done", result); + + // Check workflow's serialization - client-enqueued workflows get java_jackson by default + var wfRow = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(wfRow); + assertEquals("portable_json", wfRow.serialization()); + + // Verify the events in the database have correct serialization + var events = DBUtils.getWorkflowEvents(dataSource, workflowId); + assertEquals(3, events.size()); + + // Find each event and verify serialization + var defaultEvent = events.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var nativeEvent = events.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); + var portableEvent = events.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + + assertTrue(defaultEvent.isPresent()); + assertTrue(nativeEvent.isPresent()); + assertTrue(portableEvent.isPresent()); + + // Default setEvent inherits workflow's serialization (portable_json in this case) + assertEquals("portable_json", defaultEvent.get().serialization()); + assertEquals("java_jackson", nativeEvent.get().serialization()); + assertEquals("portable_json", portableEvent.get().serialization()); + + // Also verify the event history + var eventHistory = DBUtils.getWorkflowEventHistory(dataSource, workflowId); + assertEquals(3, eventHistory.size()); + + var defaultHist = + eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var nativeHist = eventHistory.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); + var portableHist = + eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + + assertTrue(defaultHist.isPresent()); + assertTrue(nativeHist.isPresent()); + assertTrue(portableHist.isPresent()); + + assertEquals("portable_json", defaultHist.get().serialization()); + assertEquals("java_jackson", nativeHist.get().serialization()); + assertEquals("portable_json", portableHist.get().serialization()); + } + } + /** * Tests that DBOS.send() with explicit SerializationStrategy correctly stores the serialization * format in the notifications table. From abfc21f206af3eb7d82e71130d836a24a1a2c376 Mon Sep 17 00:00:00 2001 From: Chuck B Date: Fri, 13 Feb 2026 12:18:52 -0500 Subject: [PATCH 11/11] Fix startWorkflow --- .../dbos/transact/execution/DBOSExecutor.java | 21 +- .../json/PortableSerializationTest.java | 226 ++++++++---------- 2 files changed, 115 insertions(+), 132 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 962e1252..929435c3 100644 --- a/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java +++ b/transact/src/main/java/dev/dbos/transact/execution/DBOSExecutor.java @@ -1203,11 +1203,19 @@ private WorkflowHandle executeWorkflow( } } + if (options.serialization() == null) { + if (workflow.serializationStrategy() != null) { + options = options.withSerialization(workflow.serializationStrategy().formatName()); + } + } + Integer maxRetries = workflow.maxRecoveryAttempts() > 0 ? workflow.maxRecoveryAttempts() : null; + final var foptions = options; + if (options.queueName() != null) { - var queue = queues.stream().filter(q -> q.name().equals(options.queueName())).findFirst(); + var queue = queues.stream().filter(q -> q.name().equals(foptions.queueName())).findFirst(); if (queue.isPresent()) { if (queue.get().partitionedEnabled() && options.queuePartitionKey() == null) { throw new IllegalArgumentException( @@ -1285,14 +1293,14 @@ private WorkflowHandle executeWorkflow( if (res != null) throw new DBOSWorkflowExecutionConflictException(workflowId); try { logger.debug( - "executeWorkflow task {}({}) {}", workflow.fullyQualifiedName(), args, options); + "executeWorkflow task {}({}) {}", workflow.fullyQualifiedName(), args, foptions); DBOSContextHolder.set( new DBOSContext( workflowId, parent, - options.timeoutDuration(), - options.deadline(), + foptions.timeoutDuration(), + foptions.deadline(), SerializationUtil.PORTABLE.equals(initResult.serialization()) ? SerializationStrategy.PORTABLE : SerializationStrategy.DEFAULT)); @@ -1305,7 +1313,8 @@ private WorkflowHandle executeWorkflow( logger.debug("executeWorkflow task interrupted before postInvokeWorkflowResult"); return null; } - postInvokeWorkflowResult(systemDatabase, workflowId, result, options.serialization()); + postInvokeWorkflowResult( + systemDatabase, workflowId, result, initResult.serialization()); return result; } catch (DBOSWorkflowExecutionConflictException e) { // don't persist execution conflict exception @@ -1330,7 +1339,7 @@ private WorkflowHandle executeWorkflow( throw new DBOSAwaitedWorkflowCancelledException(workflowId); } - postInvokeWorkflowError(systemDatabase, workflowId, actual, options.serialization()); + postInvokeWorkflowError(systemDatabase, workflowId, actual, initResult.serialization()); throw e; } finally { DBOSContextHolder.clear(); diff --git a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java index 231c2f0d..4a61a592 100644 --- a/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java +++ b/transact/src/test/java/dev/dbos/transact/json/PortableSerializationTest.java @@ -4,6 +4,7 @@ import dev.dbos.transact.DBOS; import dev.dbos.transact.DBOSClient; +import dev.dbos.transact.StartWorkflowOptions; import dev.dbos.transact.config.DBOSConfig; import dev.dbos.transact.database.SystemDatabase; import dev.dbos.transact.utils.DBUtils; @@ -340,135 +341,106 @@ public void errorWorkflow() { * serialization format in the database. */ @Test - public void testSetEventWithExplicitSerialization() throws Exception { + public void testSetEventWithVaryingSerialization() throws Exception { Queue testQueue = new Queue("testq"); DBOS.registerQueue(testQueue); - DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServiceImpl()); + var defsvc = DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServiceImpl()); + var portsvc = + DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServicePortableImpl()); DBOS.launch(); - // Use DBOSClient to enqueue and run the workflow - try (DBOSClient client = new DBOSClient(dataSource)) { - String workflowId = UUID.randomUUID().toString(); - - var options = - new DBOSClient.EnqueueOptions("ExplicitSerService", "eventWorkflow", "testq") - .withWorkflowId(workflowId); - - WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); - String result = handle.getResult(); - assertEquals("done", result); - - // Check workflow's serialization - client-enqueued workflows get java_jackson by default - var wfRow = DBUtils.getWorkflowRow(dataSource, workflowId); - assertNotNull(wfRow); - assertEquals("java_jackson", wfRow.serialization()); - - // Verify the events in the database have correct serialization - var events = DBUtils.getWorkflowEvents(dataSource, workflowId); - assertEquals(3, events.size()); - - // Find each event and verify serialization - var defaultEvent = events.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); - var nativeEvent = events.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); - var portableEvent = events.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); - - assertTrue(defaultEvent.isPresent()); - assertTrue(nativeEvent.isPresent()); - assertTrue(portableEvent.isPresent()); - - // Default setEvent inherits workflow's serialization (java_jackson in this case) - assertEquals("java_jackson", defaultEvent.get().serialization()); - // Native should have java_jackson (explicitly set) - assertEquals("java_jackson", nativeEvent.get().serialization()); - // Portable should have portable_json (explicitly set) - assertEquals("portable_json", portableEvent.get().serialization()); - - // Also verify the event history - var eventHistory = DBUtils.getWorkflowEventHistory(dataSource, workflowId); - assertEquals(3, eventHistory.size()); - - var defaultHist = - eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); - var nativeHist = eventHistory.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); - var portableHist = - eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); - - assertTrue(defaultHist.isPresent()); - assertTrue(nativeHist.isPresent()); - assertTrue(portableHist.isPresent()); - - assertEquals("java_jackson", defaultHist.get().serialization()); - assertEquals("java_jackson", nativeHist.get().serialization()); - assertEquals("portable_json", portableHist.get().serialization()); - } - } - - /** - * Tests that DBOS.setEvent() with explicit SerializationStrategy correctly stores the - * serialization format in the database. - */ - @Test - public void testSetEventWithPortableSerialization() throws Exception { - Queue testQueue = new Queue("testq"); - DBOS.registerQueue(testQueue); - DBOS.registerWorkflows(ExplicitSerService.class, new ExplicitSerServicePortableImpl()); - - DBOS.launch(); - - // Use DBOSClient to enqueue and run the workflow - try (DBOSClient client = new DBOSClient(dataSource)) { - String workflowId = UUID.randomUUID().toString(); - - var options = - new DBOSClient.EnqueueOptions("ExplicitSerServicePortable", "eventWorkflow", "testq") - .withWorkflowId(workflowId) - .withSerialization(SerializationStrategy.PORTABLE); - - WorkflowHandle handle = client.enqueueWorkflow(options, new Object[] {}); - String result = handle.getResult(); - assertEquals("done", result); - - // Check workflow's serialization - client-enqueued workflows get java_jackson by default - var wfRow = DBUtils.getWorkflowRow(dataSource, workflowId); - assertNotNull(wfRow); - assertEquals("portable_json", wfRow.serialization()); - - // Verify the events in the database have correct serialization - var events = DBUtils.getWorkflowEvents(dataSource, workflowId); - assertEquals(3, events.size()); - - // Find each event and verify serialization - var defaultEvent = events.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); - var nativeEvent = events.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); - var portableEvent = events.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); - - assertTrue(defaultEvent.isPresent()); - assertTrue(nativeEvent.isPresent()); - assertTrue(portableEvent.isPresent()); - - // Default setEvent inherits workflow's serialization (portable_json in this case) - assertEquals("portable_json", defaultEvent.get().serialization()); - assertEquals("java_jackson", nativeEvent.get().serialization()); - assertEquals("portable_json", portableEvent.get().serialization()); - - // Also verify the event history - var eventHistory = DBUtils.getWorkflowEventHistory(dataSource, workflowId); - assertEquals(3, eventHistory.size()); - - var defaultHist = - eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); - var nativeHist = eventHistory.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); - var portableHist = - eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); - - assertTrue(defaultHist.isPresent()); - assertTrue(nativeHist.isPresent()); - assertTrue(portableHist.isPresent()); - - assertEquals("portable_json", defaultHist.get().serialization()); - assertEquals("java_jackson", nativeHist.get().serialization()); - assertEquals("portable_json", portableHist.get().serialization()); + for (String sertype : new String[] {"defq", "portq", "defstart", "portstart"}) { + // Use DBOSClient to enqueue and run the workflow + try (DBOSClient client = new DBOSClient(dataSource)) { + String workflowId = UUID.randomUUID().toString(); + WorkflowHandle handle = null; + boolean isPortable = sertype.startsWith("port"); + + if (sertype.equals("defq")) { + var options = + new DBOSClient.EnqueueOptions("ExplicitSerService", "eventWorkflow", "testq") + .withWorkflowId(workflowId); + + handle = client.enqueueWorkflow(options, new Object[] {}); + } + if (sertype.equals("portq")) { + var options = + new DBOSClient.EnqueueOptions("ExplicitSerService", "eventWorkflow", "testq") + .withWorkflowId(workflowId) + .withSerialization(SerializationStrategy.PORTABLE); + + handle = client.enqueueWorkflow(options, new Object[] {}); + } + if (sertype.equals("defstart")) { + handle = + DBOS.startWorkflow( + () -> { + return defsvc.eventWorkflow(); + }, + new StartWorkflowOptions(workflowId)); + } + if (sertype.equals("portstart")) { + handle = + DBOS.startWorkflow( + () -> { + return portsvc.eventWorkflow(); + }, + new StartWorkflowOptions(workflowId)); + } + + String result = handle.getResult(); + assertEquals("done", result); + + // Check workflow's serialization + var wfRow = DBUtils.getWorkflowRow(dataSource, workflowId); + assertNotNull(wfRow); + var expectedSer = isPortable ? "portable_json" : "java_jackson"; + if (!expectedSer.equals(wfRow.serialization())) { + System.err.println("Expected serialization does not match in: " + sertype); + } + assertEquals(expectedSer, wfRow.serialization()); + + // Verify the events in the database have correct serialization + var events = DBUtils.getWorkflowEvents(dataSource, workflowId); + assertEquals(3, events.size()); + + // Find each event and verify serialization + var defaultEvent = events.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var nativeEvent = events.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); + var portableEvent = + events.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + + assertTrue(defaultEvent.isPresent()); + assertTrue(nativeEvent.isPresent()); + assertTrue(portableEvent.isPresent()); + + // Default setEvent inherits workflow's serialization + assertEquals(expectedSer, defaultEvent.get().serialization()); + // Native should have java_jackson (explicitly set) + assertEquals("java_jackson", nativeEvent.get().serialization()); + // Portable should have portable_json (explicitly set) + assertEquals("portable_json", portableEvent.get().serialization()); + + // Also verify the event history + var eventHistory = DBUtils.getWorkflowEventHistory(dataSource, workflowId); + assertEquals(3, eventHistory.size()); + + var defaultHist = + eventHistory.stream().filter(e -> e.key().equals("defaultEvent")).findFirst(); + var nativeHist = + eventHistory.stream().filter(e -> e.key().equals("nativeEvent")).findFirst(); + var portableHist = + eventHistory.stream().filter(e -> e.key().equals("portableEvent")).findFirst(); + + assertTrue(defaultHist.isPresent()); + assertTrue(nativeHist.isPresent()); + assertTrue(portableHist.isPresent()); + + assertEquals(expectedSer, defaultHist.get().serialization()); + assertEquals("java_jackson", nativeHist.get().serialization()); + assertEquals("portable_json", portableHist.get().serialization()); + } } } @@ -550,7 +522,8 @@ public static class EventSetterServiceImpl implements EventSetterService { @Workflow(name = "setEventWorkflow") @Override public String setEventWorkflow() { - // Set event without explicit serialization - should inherit from workflow context + // Set event without explicit serialization - should inherit from workflow + // context DBOS.setEvent("myKey", "myValue"); return "eventSet"; } @@ -591,7 +564,8 @@ public void testPortableWorkflowDefaultSerialization() throws Exception { assertNotNull(row); assertEquals("portable_json", row.serialization()); - // Verify the event inherited portable serialization (since it was set without explicit type) + // Verify the event inherited portable serialization (since it was set without + // explicit type) var events = DBUtils.getWorkflowEvents(dataSource, workflowId); assertEquals(1, events.size()); assertEquals("myKey", events.get(0).key());