Skip to content

workflow.SignalWorkflow silently corrupts large integer values (> 2^53) via float64 precision loss #471

Description

@Niels-Be

Version: v1.4.1

Description

workflow.SignalWorkflow silently truncates large integer values (any int64/uint64/uint exceeding 2^53 ≈ 9.0e15) in the signal payload. The value arrives at the receiver rounded to the nearest float64-representable integer. No error is returned and no warning is logged — the corruption is invisible to the caller.

Root Cause

workflow.SignalWorkflow[T] is implemented by scheduling a system activity:

// workflow/signal.go
func SignalWorkflow[T any](ctx Context, instanceID string, name string, arg T) Future[any] {
    var a *signals.Activities
    return ExecuteActivity[any](ctx, ..., a.DeliverWorkflowSignal, instanceID, name, arg)
}

DeliverWorkflowSignal accepts arg as interface{}:

// internal/signals/activities.go
func (a *Activities) DeliverWorkflowSignal(ctx context.Context, instanceID, signalName string, arg interface{}) error {
    return a.Signaler.SignalWorkflow(ctx, instanceID, signalName, arg)
}

When go-workflows serialises the activity arguments to JSON and then deserialises them back into the function's parameter types, the arg interface{} parameter causes the standard encoding/json behaviour: all JSON numbers are decoded into float64. Since float64 only has 53 bits of mantissa, any integer larger than 2^53 is rounded to the nearest representable value. The correctly-typed signal channel on the receiver side receives the already-corrupted value — there is no second chance for the type information to rescue the precision.

Reproduction

type signalTestPayload struct {
    Id uint
}

func Test_SignalWorkflow_Uint_PrecisionLoss(t *testing.T) {
    const (
        receiverInstanceID = "receiver"
        signalChannel      = "test-signal"
    )

    testPayload := signalTestPayload{Id: 123456789012345678} // > 2^53

    var received signalTestPayload

    receiverWorkflow := func(ctx workflow.Context) error {
        ch := workflow.NewSignalChannel[signalTestPayload](ctx, signalChannel)
        payload, _ := ch.Receive(ctx)
        received = payload
        return nil
    }

    senderWorkflow := func(ctx workflow.Context) error {
        sub := workflow.CreateSubWorkflowInstance[any](ctx, workflow.SubWorkflowOptions{
            InstanceID: receiverInstanceID,
        }, receiverWorkflow)

        if _, err := workflow.SignalWorkflow[signalTestPayload](ctx, receiverInstanceID, signalChannel, testPayload).Get(ctx); err != nil {
            return err
        }

        _, err := sub.Get(ctx)
        return err
    }

    wt := tester.NewWorkflowTester[any](senderWorkflow, tester.WithTestTimeout(5*time.Second))
    wt.Registry().RegisterWorkflow(receiverWorkflow)
    wt.Execute(context.Background())

    // FAILS:
    // expected: 0x1b69b4ba630f34e (123456789012345678)
    // actual:   0x1b69b4ba630f350 (123456789012345680)
    require.Equal(t, testPayload.Id, received.Id)
}

Expected Behaviour

The value received on the signal channel equals the value that was sent, regardless of its magnitude.

Actual Behaviour

expected: 0x1b69b4ba630f34e  (123456789012345678)
actual:   0x1b69b4ba630f350  (123456789012345680)

The value is silently rounded. The 2-unit difference is the standard float64 rounding for this number.

Suggested Fix

The arg interface{} parameter in DeliverWorkflowSignal should be replaced with a typed payload (e.g. json.RawMessage / payload.Payload) so that the concrete type information present at the call site of SignalWorkflow[T] is preserved through the round-trip. The type is known at serialisation time and should be serialised directly rather than being widened to interface{} before the JSON round-trip occurs.

An existing workaround is to bypass workflow.SignalWorkflow entirely and call client.SignalWorkflow directly from an activity — this calls the backend with the concrete type, so the JSON is deserialised into the correct target type from the start.

Metadata

Metadata

Labels

No labels
No labels

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions