transitions icon indicating copy to clipboard operation
transitions copied to clipboard

Community: Should AsyncMachine continue handling `CancelledErrors` raised by unfinished events silently?

Open aleneum opened this issue 5 months ago • 1 comments

Current Behavior

As of now AsyncMachine will handle CancelledErrors caused by cancelling tasks that are still running while a new event is triggered.

Question

Do you consider this useful and convenient or would you prefer to handle cancellation events yourself via for instance try/except or on_exception?

Example

from transitions.extensions import AsyncMachine
import asyncio


class Model:

    def __init__(self):
        self.data = []

    async def doing_task_1(self):
        print("Doing task 1...")
        await asyncio.sleep(2)
        self.data.append(1)
        await self.done()

    async def doing_task_2(self):
        print("Doing task 2...")
        await asyncio.sleep(2)
        self.data.append(2)
        await self.done()

    async def doing_task_3(self):
        print("Doing task 3...")
        self.data.append(3)
        await self.done()

states = ["idle", "busy"] 
transitions = [
    {"trigger": "do_task_1", "source": "*", "dest": "busy", "after": "doing_task_1"},
    {"trigger": "do_task_2", "source": "*", "dest": "busy", "after": "doing_task_2"},
    {"trigger": "do_task_3", "source": "*", "dest": "busy", "after": "doing_task_3"},
    {"trigger": "done", "source": "busy", "dest": "idle"},
]

model = Model()
m = AsyncMachine(model=model, states=states, transitions=transitions, initial="idle")


async def main():
    asyncio.create_task(model.do_task_1())  # won't finish
    await asyncio.sleep(0.1)
    asyncio.create_task(model.do_task_2())  # won't finish
    await asyncio.sleep(0.1)
    asyncio.create_task(model.do_task_3())  # does finish
    await asyncio.sleep(0.1)
    assert model.is_idle()
    assert model.data == [3]

asyncio.run(main())

aleneum avatar Jul 03 '25 10:07 aleneum

As the source of this discussion, I believe that handling the exeption ourselves would be better. In a state machine context, having a transition from A to B be halfway done and then cancelling it to start a transition from A to C results in nether B or C unless both are aware that it is a possibility that their starting state may be a dirty A and that their transition may get cancelled.

import asyncio
from transitions.extensions.asyncio import AsyncMachine


class Model:

    def __init__(self):
        self.counter = 0

    async def on_enter_a(self):
        self.counter = 0

    async def on_exit_a(self):
        self.counter += 1
        await asyncio.sleep(1)

states = ["a", "b", "c"]
transitions = [
    {"trigger": "to_b", "source": "a", "dest": "b"},
    {"trigger": "to_c", "source": "a", "dest": "c"},
]
model = Model()
m = AsyncMachine(model=model, states=states, initial="a",
                 transitions=transitions)

async def main():
    to_b = asyncio.create_task(model.to_b())
    await asyncio.sleep(0.5)
    await model.to_c()
    assert model.counter == 2
# State A exit was triggered twice despite no transition back to it,
# B and C could have expected counter to be at 1, but it's not.
    to_b_res = await to_b
# No exception, only a 'False' which could have been caused by plenty of other issues

asyncio.run(main())

I don't expect the AsyncMachine to handle things differently than a sync Machine, as such I think that a transition should be unitary. In principle, I would have expected the old transitions to raise a CancelledError or the new one to raise a MachineNotFreeError (as long as the transition queue option is not activated of course).

Salier13 avatar Jul 03 '25 18:07 Salier13