Skip to content

Commit c6386e9

Browse files
authored
Prevent post-return activity cancel from killing pool thread (#1654)
* Prevent post-return activity cancel from killing pool thread * Prevent post-return activity cancel from killing pool thread
1 parent 663ea64 commit c6386e9

2 files changed

Lines changed: 167 additions & 5 deletions

File tree

temporalio/worker/_activity.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -775,6 +775,20 @@ def set_thread_id(self, thread_id: int) -> None:
775775
with self._lock:
776776
self._thread_id = thread_id
777777

778+
@contextmanager
779+
def active_thread(self) -> Iterator[None]:
780+
thread_id = threading.current_thread().ident
781+
if thread_id is not None:
782+
self.set_thread_id(thread_id)
783+
try:
784+
yield None
785+
finally:
786+
if thread_id is not None:
787+
with self._lock:
788+
if self._thread_id == thread_id:
789+
self._thread_id = None
790+
self._pending_exception = None
791+
778792
def raise_in_thread(self, exc_type: type[Exception]) -> None:
779793
with self._lock:
780794
self._pending_exception = exc_type
@@ -939,10 +953,6 @@ def _execute_sync_activity(
939953
fn: Callable[..., Any],
940954
*args: Any,
941955
) -> Any:
942-
if cancel_thread_raiser:
943-
thread_id = threading.current_thread().ident
944-
if thread_id is not None:
945-
cancel_thread_raiser.set_thread_id(thread_id)
946956
if isinstance(heartbeat, SharedHeartbeatSender):
947957

948958
def heartbeat_fn(*details: Any) -> None:
@@ -968,7 +978,11 @@ def heartbeat_fn(*details: Any) -> None:
968978
cancellation_details=cancellation_details,
969979
)
970980
)
971-
return fn(*args)
981+
if not cancel_thread_raiser:
982+
return fn(*args)
983+
else:
984+
with cancel_thread_raiser.active_thread():
985+
return fn(*args)
972986

973987

974988
class SharedStateManager(ABC):

tests/worker/test_activity.py

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1419,6 +1419,154 @@ def some_activity() -> str:
14191419
assert result.result == "context var: some value!"
14201420

14211421

1422+
@activity.defn
1423+
def post_return_cancel_activity() -> str:
1424+
return "done"
1425+
1426+
1427+
@activity.defn
1428+
def post_return_cancel_probe_activity() -> str:
1429+
return "probe"
1430+
1431+
1432+
class PostReturnCancelInterceptor(Interceptor):
1433+
def __init__(
1434+
self, after_return_started: asyncio.Event, allow_completion: asyncio.Event
1435+
) -> None:
1436+
super().__init__()
1437+
self.after_return_started = after_return_started
1438+
self.allow_completion = allow_completion
1439+
1440+
def intercept_activity(
1441+
self, next: ActivityInboundInterceptor
1442+
) -> ActivityInboundInterceptor:
1443+
return PostReturnCancelActivityInbound(
1444+
next, self.after_return_started, self.allow_completion
1445+
)
1446+
1447+
1448+
class PostReturnCancelActivityInbound(ActivityInboundInterceptor):
1449+
def __init__(
1450+
self,
1451+
next: ActivityInboundInterceptor,
1452+
after_return_started: asyncio.Event,
1453+
allow_completion: asyncio.Event,
1454+
) -> None:
1455+
super().__init__(next)
1456+
self.after_return_started = after_return_started
1457+
self.allow_completion = allow_completion
1458+
1459+
async def execute_activity(self, input: ExecuteActivityInput) -> Any:
1460+
result = await super().execute_activity(input)
1461+
if activity.info().activity_type == "post_return_cancel_activity":
1462+
self.after_return_started.set()
1463+
await self.allow_completion.wait()
1464+
return result
1465+
1466+
1467+
async def test_sync_activity_cancel_after_return_does_not_kill_thread_pool_worker(
1468+
client: Client,
1469+
worker: ExternalWorker,
1470+
env: WorkflowEnvironment,
1471+
shared_state_manager: SharedStateManager,
1472+
):
1473+
if env.supports_time_skipping:
1474+
pytest.skip("Test requires real worker-side timeout cancellation delivery")
1475+
1476+
def alive_thread_count(executor: ThreadPoolExecutor) -> int:
1477+
return sum(1 for thread in list(executor._threads) if thread.is_alive())
1478+
1479+
after_return_started = asyncio.Event()
1480+
allow_completion = asyncio.Event()
1481+
act_task_queue = str(uuid.uuid4())
1482+
1483+
with ThreadPoolExecutor(max_workers=1) as executor:
1484+
with pytest.warns(
1485+
UserWarning,
1486+
match="Worker max_concurrent_activities is 2 but activity_executor's max_workers is only",
1487+
):
1488+
act_worker = Worker(
1489+
client,
1490+
task_queue=act_task_queue,
1491+
activities=[
1492+
post_return_cancel_activity,
1493+
post_return_cancel_probe_activity,
1494+
],
1495+
activity_executor=executor,
1496+
interceptors=[
1497+
PostReturnCancelInterceptor(after_return_started, allow_completion)
1498+
],
1499+
max_concurrent_activities=2,
1500+
shared_state_manager=shared_state_manager,
1501+
)
1502+
1503+
async with act_worker:
1504+
try:
1505+
timed_out_workflow = asyncio.create_task(
1506+
client.execute_workflow(
1507+
"kitchen_sink",
1508+
KSWorkflowParams(
1509+
actions=[
1510+
KSAction(
1511+
execute_activity=KSExecuteActivityAction(
1512+
name="post_return_cancel_activity",
1513+
task_queue=act_task_queue,
1514+
start_to_close_timeout_ms=200,
1515+
retry_max_attempts=1,
1516+
)
1517+
)
1518+
]
1519+
),
1520+
id=str(uuid.uuid4()),
1521+
task_queue=worker.task_queue,
1522+
)
1523+
)
1524+
await asyncio.wait_for(after_return_started.wait(), timeout=5)
1525+
1526+
with pytest.raises(WorkflowFailureError) as err:
1527+
await timed_out_workflow
1528+
timeout = assert_activity_error(err.value)
1529+
assert isinstance(timeout, TimeoutError)
1530+
assert timeout.type == TimeoutType.START_TO_CLOSE
1531+
1532+
activity_worker = act_worker._activity_worker
1533+
assert activity_worker
1534+
for _ in range(50):
1535+
if any(
1536+
running.cancelled_event and running.cancelled_event.is_set()
1537+
for running in activity_worker._running_activities.values()
1538+
):
1539+
break
1540+
await asyncio.sleep(0.1)
1541+
else:
1542+
pytest.fail("Timed out waiting for activity cancellation")
1543+
1544+
probe_result = await asyncio.wait_for(
1545+
client.execute_workflow(
1546+
"kitchen_sink",
1547+
KSWorkflowParams(
1548+
actions=[
1549+
KSAction(
1550+
execute_activity=KSExecuteActivityAction(
1551+
name="post_return_cancel_probe_activity",
1552+
task_queue=act_task_queue,
1553+
start_to_close_timeout_ms=30000,
1554+
retry_max_attempts=1,
1555+
)
1556+
)
1557+
]
1558+
),
1559+
id=str(uuid.uuid4()),
1560+
task_queue=worker.task_queue,
1561+
),
1562+
timeout=5,
1563+
)
1564+
assert probe_result == "probe"
1565+
assert alive_thread_count(executor) == 1
1566+
finally:
1567+
allow_completion.set()
1568+
1569+
14221570
@activity.defn
14231571
async def local_without_schedule_to_close_activity() -> str:
14241572
return "some-activity"

0 commit comments

Comments
 (0)