[Bug] set_dynamic_signal_handler delivers buffered signals grouped by name instead of in arrival order · Issue #1996 · temporalio/sdk-python · GitHub
Skip to content

[Bug] set_dynamic_signal_handler delivers buffered signals grouped by name instead of in arrival order #1996

Closed as not planned

Description

@RachelWanggg

What are you really trying to do?

A cart workflow loads its state with an activity, then registers a dynamic signal handler with workflow.set_dynamic_signal_handler and routes events like add, clear and checkout by signal name. The client starts the workflow and sends the shopper's events right away, while the load activity is still running.

Describe the bug

Signals that arrive before the dynamic handler is set are buffered. When the handler is set, they are delivered grouped by signal name, in the order each name was first seen, not in the order they arrived. For add apple, clear, add pear, checkout, the handler sees add apple, add pear, clear, checkout, so the cart is empty at checkout instead of holding pear.

Nothing fails and nothing is logged. The workflow just computes a different state from the signals in its history. The same happens when the signals arrive before the first workflow task (sent right after start while workers are busy) and the handler is set at the top of run.

Minimal Reproduction

import asyncio
import uuid
from collections.abc import Sequence
from datetime import timedelta

from temporalio import activity, workflow
from temporalio.common import RawValue
from temporalio.testing import WorkflowEnvironment
from temporalio.worker import Worker


@activity.defn
async def load_cart(cart_id: str) -> list[str]:
    # Stands in for a database read
    await asyncio.sleep(1)
    return []


@workflow.defn
class Cart:
    def __init__(self) -> None:
        self.items: list[str] = []
        self.handled: list[str] = []
        self.checked_out = False

    @workflow.run
    async def run(self, cart_id: str) -> tuple[list[str], list[str]]:
        self.items = await workflow.execute_activity(
            load_cart, cart_id, start_to_close_timeout=timedelta(seconds=10)
        )

        def on_event(name: str, args: Sequence[RawValue]) -> None:
            item = workflow.payload_converter().from_payload(args[0].payload, str)
            self.handled.append(f"{name} {item}".strip())
            if name == "add":
                self.items.append(item)
            elif name == "clear":
                self.items.clear()
            elif name == "checkout":
                self.checked_out = True

        workflow.set_dynamic_signal_handler(on_event)
        await workflow.wait_condition(lambda: self.checked_out)
        return self.handled, self.items


async def main() -> None:
    async with await WorkflowEnvironment.start_local() as env:
        async with Worker(
            env.client, task_queue="tq", workflows=[Cart], activities=[load_cart]
        ):
            for run in range(1, 6):
                cart = await env.client.start_workflow(
                    Cart.run, "cart-1", id=f"cart-{uuid.uuid4()}", task_queue="tq"
                )
                # The shopper acts while the cart is still loading
                sent = [("add", "apple"), ("clear", ""), ("add", "pear"), ("checkout", "")]
                for name, item in sent:
                    await cart.signal(name, item)
                handled, items = await cart.result()
                print(f"run {run}: handled {handled} -> cart {items}")


if __name__ == "__main__":
    asyncio.run(main())

On main (646d70f), every run:

run 1: handled ['add apple', 'add pear', 'clear', 'checkout'] -> cart []
run 2: handled ['add apple', 'add pear', 'clear', 'checkout'] -> cart []
run 3: handled ['add apple', 'add pear', 'clear', 'checkout'] -> cart []
run 4: handled ['add apple', 'add pear', 'clear', 'checkout'] -> cart []
run 5: handled ['add apple', 'add pear', 'clear', 'checkout'] -> cart []

Expected: handled ['add apple', 'clear', 'add pear', 'checkout'] -> cart ['pear'].

Environment/Versions

  • OS and processor: macOS, Apple Silicon
  • SDK: main at 646d70f (the same code is in 1.34.0), Python 3.12
  • Dev server from WorkflowEnvironment.start_local() (Temporal CLI 1.9.1, Server 1.32.0)

Additional context

  • _apply_signal_workflow buffers signals in a dict keyed by signal name, and workflow_set_signal_handler drains it name by name for a dynamic handler. This dates from Phase 2 - Initial workflow implementation #21.
  • A named handler set with workflow.set_signal_handler only takes its own signals, so their order is kept. A @workflow.signal(dynamic=True) method gets the same signals in arrival order, because nothing is buffered for it.
  • The TypeScript SDK keeps one bufferedSignals array and a default signal handler drains it in order (dispatchBufferedSignals). The .NET SDK keeps one list and applies all of it in order when DynamicSignal is set (DynamicSignal).
  • Changing the order changes replay for histories that already went through this path, so a fix needs a workflow logic flag.

Activity

  1. tconley1428 commented on Oct 21, 2026

    @tconley1428
    Contributor

    For prioritization purposes, can you speak to how this impacts you? Was this something you actually encountered, and is there a reason setting the dynamic handler earlier, or using a static one, is not an option?

  2. RachelWanggg commented on Oct 21, 2026

    @RachelWanggg
    ContributorAuthor

    I didn't run into it in an app. I found it reading the signal buffering code and reproduced it with a real worker. A static @workflow.signal(dynamic=True) method keeps the order, so that works as a workaround. Setting the handler earlier doesn't fully avoid it, though: even from __init__, signals that arrive with the first workflow task are still grouped by name.

  3. tconley1428 commented on Oct 21, 2026

    @tconley1428
    Contributor

    I'm inclined not to fix this since it is niche with no known impact. If anyone hits it please reopen.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions