Skip to content

Conversation

@maxdml
Copy link
Collaborator

@maxdml maxdml commented Nov 10, 2025

  • use JSON for encoding/decoding
  • lift encode/decode out of the system database
  • distinguish nil values (stored as NULL) in Postgres
  • return json strings on the ListWorkflows GetWorkflowSteps paths
  • Add comprehensive testing suite

On the get wf steps / list wf path, the contract is that if the value is nil, you know it was storing a nil value. Else, the value is a JSON string of your workflow's or step's T.

// Wait for workflow completion
proceedSignal.Set() // Allow the workflow to proceed to step two
result, err := resumeHandle.GetResult()
resultAny, err := resumeHandle.GetResult()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could make the resume handle typed so the returned polling handle can be of type T as well. (Right now on this path, the user gets a polling handle typed any, and the result is already json decoded into an any value, losing its type information)

Comment on lines +542 to +551
resultAny, err := h.GetResult()
require.NoError(t, err, "failed to get result from recovered root workflow handle")
castedResult, ok := result.([]int)
require.True(t, ok, "expected result to be of type []int for root workflow, got %T", result)
// re-encode and decode the result from []interface{} to []int
encodedResult, ok := resultAny.([]any)
require.True(t, ok, "expected result to be a []any")
jsonBytes, err := json.Marshal(encodedResult)
require.NoError(t, err, "failed to marshal result to JSON")
var castedResult []int
err = json.Unmarshal(jsonBytes, &castedResult)
require.NoError(t, err, "failed to decode result to []int")
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

on this path the result was already decoded into any, which lost type info. This is because recovery handles are untyped. Just needed for testing as recovery handles are not public.

Comment on lines +22 to +24
// Check if the value is nil (for pointer types, slice, map, etc.)
if isNilValue(data) {
// For nil values, return nil pointer
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In Go, nil values are not necessarily pointers. We need the helper function for the case where T is any.

var p *int  // p is nil
var v any = p  // v is NOT nil (it's an interface containing a nil pointer)

fmt.Println(p == nil)  // true
fmt.Println(v == nil)  // false! The interface itself is not nil

Comment on lines +1418 to 1424
type stepInfo struct {
StepID int // The sequential ID of the step within the workflow
StepName string // The name of the step function
Output *string // The output returned by the step (if any)
Error error // The error returned by the step (if any)
ChildWorkflowID string // The ID of a child workflow spawned by this step (if applicable)
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Internal struct so we can distinguish easily between empty outputs and nil outputs. In v1 we can modify StepInfo's Output field to be of type *string instead of any.

Comment on lines +214 to +218
if _, ok := h.dbosContext.(*dbosContext); !ok {
return *new(R), newWorkflowExecutionError(workflowState.workflowID, fmt.Errorf("invalid DBOSContext: expected *dbosContext"))
}
serializer := newJSONSerializer[R]()
encodedOutput, encErr := serializer.Encode(decodedResult)
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as an optimization we could pass the encoded value around the workflowOutcome...

Comment on lines +1412 to +1415
// Handle nil message
if msg == nil {
return *new(R), nil
}
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Technically should never happen because we have a not null constraint on the message column.


Launch(dbosCtx)

t.Run("SendRecvSuccess", func(t *testing.T) {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed a race in this suite.

@maxdml maxdml marked this pull request as ready for review November 11, 2025 00:50
Copy link
Member

@kraftp kraftp left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is getting closer! Questions:

  1. I don't like how there's a user-facing distinction between typed and untyped workflow handles. All handles should be typed.
  2. What exactly are list workflows and list steps returning now? The return types still seem to be any, but we don't have type info.
  3. How are we handling nil values? Why do we need an isNilValue function and why is there special-casing if it's true?

@maxdml
Copy link
Collaborator Author

maxdml commented Nov 11, 2025

  1. I don't like how there's a user-facing distinction between typed and untyped workflow handles. All handles should be typed.

Today only the client methods return untyped handles (RetrieveWorkflow, ResumeWorkflow, etc). That's because we don't expose generic package level methods (except for enqueue), but only interface methods on the client. (Except for enqueue)

That being said, this PR allow users to declare workflows with any signature.

  1. What exactly are list workflows and list steps returning now? The return types still seem to be any, but we don't have type info.

They return JSON strings or an zero any value (nil). There is an argument for changing the public interface and having the steps output always be *string. For WorkflowStatus, we'd need to make it generic so it can be *string in the List paths, and whatever type it is supposed to be in every other paths (e.g., GetResult)

The former could be done rather simply in this PR but will be a breaking change. I expect quite a bit more of refactor for the later (which would also be a breaking change).

  1. How are we handling nil values? Why do we need an isNilValue function and why is there special-casing if it's true?

We are storing nil values as NULL in the database. As discussed, we need some form of special marker like _DBOS_NIL_ that'll interpret this specially. isNilValue is necessary for the case where T is any, and wraps a nil-able variable. I'd rather take this specific point in another PR.

@maxdml maxdml merged commit 436704f into main Nov 11, 2025
6 checks passed
@maxdml maxdml deleted the json-serializer branch November 11, 2025 23:35
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants