Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Initial FSM PoC #4669

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 9 commits into
base: master
Choose a base branch
from
Draft

Initial FSM PoC #4669

wants to merge 9 commits into from

Conversation

Bibo-Joshi
Copy link
Member

@Bibo-Joshi Bibo-Joshi commented Feb 4, 2025

Works on #2770

"It has been a while since we openend #2770" would be an understatement. Yet here we are :D
Finally, I got around to draft up a somewhat proper PoC for my ideas outlined in #2770 (comment). I also attached an example that showcases how the FSM setup can be used. You'll need β‰₯2 accounts to test it.

To be clear: as of 2025-02-04 this PR does by far not include everything mentioned in #2770 (comment). I hope to work on it in the nearish-future. Continued work on this PR could be greatly supported by intermediate reviews, comments & discussions :)


ToDos / Ideas / Thoughts

(Listing here only things that came to my mind explicitly while working on this PR, not copying everything from #2770)

  • Using context.job_queue.run_once for conversation timeouts is relatively easy, but would have to repeated everywhere which also holds for the cancellation. Maybe we can think of a clever way to abstract that for the user.
  • Having both groups and states makes the "who handles what" logic more involved. Do we do "for state in states: for group in state" or "for group in groups: for state in group"? Also State.ANY and things like StateA | StateB make it necessary that one update can be handled by multiple states. All this at least needs proper documentation about what's going on. Additionally, we should think carefully about what we want here.
  • I'm unsure about if get_active_key_state should be async or not:
    • I feel like it should not make too much async operations - since it's part of the logic in App.process_update, it should be as quick as possible
    • If we want to support lazy-loading, this would probably be the place were it comes into play - depending on how we design the persistence connection
    • If states are set within get_active_key_state (as is done in the fsm example I added), then I'm wondering if one should use a semaphore for that. If one would do that, this could lead to major delays in process_update
  • Instead of BoundedSemaphore(1), we could probably just use Lock as synchronization primitive

@Bibo-Joshi Bibo-Joshi added πŸ›  refactor change type: refactor πŸ”Œ enhancement pr description: enhancement labels Feb 4, 2025

This comment was marked as outdated.

Copy link
Member

@Poolitzer Poolitzer left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just some first impressions, once I gain a further understanding I can give more

self.admin_id = admin_id
super().__init__()

def _get_admin_state(self) -> tuple[State, int]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

not sure I like this being private/protected/however the underscore is called

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So far I see no reason why someone should be allowed to all USM(…).get_admin_state … then again this is only an example, so I don't care :D

super().__init__()

def _get_admin_state(self) -> tuple[State, int]:
return self._states[self.admin_id]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That should not be the private function right

def _get_admin_state(self) -> tuple[State, int]:
return self._states[self.admin_id]

def get_state_info(self, update: object) -> StateInfo[Optional[int]]:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The type hint here is linked to the FSM class type hint above right? I can't have one be a string one be an integer.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct, the type variable specifying the type of keys you use.

Comment on lines +1518 to +1519
if not state_handlers[group]:
del state_handlers[group]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this remove the group if its empty? Can we comment it

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, that's basically the same as state_handlers.pop(group, None), I think. It will call state_handlers.__del__(group). IMHO this is basic python syntax 😬


from telegram.ext import JobQueue

_KT = TypeVar("_KT", bound=Hashable)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Im back with my thinking about private stuff. Why do we private it here, not in other files; should we. Hm.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I feel you :D if machine was a public module like telegram.constants, it would have to be protected IMO. Since it's not and we expose the relevant elements only via telegram.ext, it's not so important. Making it clearer what the content exposed by this module is still doesn't hurt.
If you want to go deeper into the rabbit hole: https://discuss.python.org/t/add-the-export-keyword-to-python/28444/6

Comment on lines +165 to +166
application.fsm = UserSupportMachine(admin_id=123456)
application.fsm.set_job_queue(application.job_queue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

these should be part of the builder and the second one set by default imo

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, absolutely. #PoC πŸ‘Ό

return f"FSM_Job_{'_'.join(str(hash(k)) for k in keys)}"

def set_job_queue(self, job_queue: "JobQueue") -> None:
self.__job_queue = weakref.ref(job_queue)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why weakref wont it/the user experience break if the supplied job queue gets destroyed

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There is job_queue.application. that's also weakref b/c we have a cyclic reference: application is application.job_queue.application. Now we also have application is application.fsm.job_queue.application. I'm not 100% sure if this is strictly necessary, but it seemed safer to me πŸ€” I can at very least add a comment.

Comment on lines +166 to +167
if raise_exception:
raise RuntimeError("JobQueue was garbage collected")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ha here see. why.

def __init__(self, uid: Optional[str] = None):
effective_uid = uid or uuid4().hex
if effective_uid in self.__knows_uids:
raise ValueError(f"Duplicate UID: {effective_uid} already registered")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is the error saying UID correct? I would understand the optional type hint to be anything hashable

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I don't get you. What do you think is wrong? the phrasing of the error message?



class State(abc.ABC):
__knows_uids: ClassVar[set[str]] = set()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wait I must really misunderstand smth, how does this work across states

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uh … that's how class variables work? :D In the init self.__knows_uids is basically the same as State.__knows_uids …

@Poolitzer
Copy link
Member

A thought I had: Since this is such a huge change for something so integral to our project, should we provide a beta version/implementation after we are satisfied with the status and announce it to get people to try it out and provide feedback before we make a first release?

@Bibo-Joshi
Copy link
Member Author

A thought I had: Since this is such a huge change for something so integral to our project, should we provide a beta version/implementation after we are satisfied with the status and announce it to get people to try it out and provide feedback before we make a first release?

That is indeed a very good idea πŸ‘
My thoughts on this are roughly like this:

  • the overall change should (hopefully) be such that nothing changes if you just don't use the new states.
  • that way we can introduce the refactoring & deprecate the existing CH without braking changes.
  • We can mark the status as "public beta feature" and at some point we can say "well, starting with version 25.0, states are now marked as stable".

that way we wouldn't have to do any additional actual beta releases.
If we prefer to have a proper beta release, I'm open to that. We could e.g. do states update on a 23.0bx line that gets only updates for states while other stuff (especially API updates only go into 22.x) . We shouldn't do that for too long IMO, we saw how much effort that takes with v20.
Encouraging people to try out a branch is ofc an easy option.

@Poolitzer
Copy link
Member

I dont get the first point, what do you mean with not using new states. In conjunction with ConvHandler? I thought this is supposed to fully replace it. In that case I would like a big announcement at least (maybe also in readme or so).

We might not really need an extra release now that I think of it, just make a big fuss about it before calling it stable and doing the deprecation error warning.

@Bibo-Joshi
Copy link
Member Author

I dont get the first point, what do you mean with not using new states. In conjunction with ConvHandler? I thought this is supposed to fully replace it. In that case I would like a big announcement at least (maybe also in readme or so).

Yes, this is supposed to replace CH. But, since this is acting on a different level than handlers, the conversationhandlerbot.py example will still run as before. We should throw hella-big warnings if states & CH are used in conjunction, true.
All other features that are independent of CH will also all run the same. all keys will always be in state "IDLE".

We might not really need an extra release now that I think of it, just make a big fuss about it before calling it stable and doing the deprecation error warning.

πŸ‘

@Poolitzer
Copy link
Member

Ah yes you mean in the transition period alright. Okay so lets do a release when we are happy and announce that its there and see if we get feedback, and then do a deprecation later. Perfect.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
πŸ”Œ enhancement pr description: enhancement πŸ›  refactor change type: refactor
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants