Repository navigation
[Bug] set_dynamic_signal_handler delivers buffered signals grouped by name instead of in arrival order #1996
Description
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:
mainat 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_workflowbuffers signals in a dict keyed by signal name, andworkflow_set_signal_handlerdrains 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_handleronly 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
bufferedSignalsarray and a default signal handler drains it in order (dispatchBufferedSignals). The .NET SDK keeps one list and applies all of it in order whenDynamicSignalis set (DynamicSignal). - Changing the order changes replay for histories that already went through this path, so a fix needs a workflow logic flag.
Activity
tconley1428 commentedon Oct 21, 2026
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?
RachelWanggg commentedon Oct 21, 2026
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.tconley1428 commentedon Oct 21, 2026
I'm inclined not to fix this since it is niche with no known impact. If anyone hits it please reopen.