Skip to content

messenger pattern - #1084

Draft
rmorshea wants to merge 1 commit into
reactive-python:mainfrom
rmorshea:messenger-pattern
Draft

messenger pattern#1084
rmorshea wants to merge 1 commit into
reactive-python:mainfrom
rmorshea:messenger-pattern

Conversation

@rmorshea

@rmorshearmorshea commented Jul 3, 2023

Copy link
Copy Markdown
Collaborator

By submitting this pull request you agree that all contributions to this project are made under the MIT license.

Issues

Currently, while the client and server have the ability to specify different message types, the server has no way of broadcasting messages other than layout events. Doing so is necessary for a number of different use cases...

Solution

Implement a generic Messenger class that allows for users to send/receive arbitrary messages to/from the client. Users will gain access to a Messenger via the use_messenger hook. Usage could look like one of the following...

Using start_consumer and start_producer:

@componentdefdemo_producer_consumer():
msgr=use_messenger()
use_effect(lambda: msgr.start_consumer("my-message-type", my_message_consumer))
use_effect(lambda: msgr.start_producer(my_message_producer))
asyncdefmy_message_consumer(msg):
...
asyncdefmy_message_producer():
whileTrue:
yield ...

Using send and receive:

@componentdefdemo_send_receive():
msgr=use_messenger()
@use_effectasyncdefstart_consumer():
asyncformsginmsgr.consume("my-message-type"):
...
@use_effectasyncdefstart_producer():
whileTrue:
awaitmsgr.send("my-message-type", ...)

Ultimately, the start_consumer and start_producer methods are convenience methods that call send and receive under the hood.

Checklist

  • Tests have been included for all bug fixes or added functionality.
  • The changelog.rst has been updated with any significant changes.

Comment on lines +22 to +40
def start_producer(self, producer: Callable[[], AsyncIterator[Message]]) -> None:
"""Add a message producer"""

async def _producer() -> None:
async for message in producer():
await self.send(message)

self._task_group.start_soon(_producer)

def start_consumer(
self, message_type: str, consumer: Callable[[Message], Awaitable[None]]
) -> None:
"""Add a message consumer"""

async def _consumer() -> None:
async for message in self.receive(message_type):
self._task_group.start_soon(consumer, message)

self._task_group.start_soon(_consumer)

Copy link
Copy Markdown
CollaboratorAuthor

Choose a reason for hiding this comment

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

These need to return callbacks to stop the consumer/producer - this will work well with use_effect.

@rmorshea
rmorshea requested a review from ArchmongerJuly 4, 2023 18:22
@rmorshearmorshea changed the title initial work on messenger patternmessenger patternJul 4, 2023
@Archmonger

Archmonger commented Jul 4, 2023

Copy link
Copy Markdown
Contributor

Unfortunately, async for msg in msgr.consume("my-message-type"): is fundamentally flawed since it relies on our async use_effect.

We would need a stateful async paradigm, similar to Django Channels consumers.

@rmorshea

rmorshea commented Jul 4, 2023

Copy link
Copy Markdown
CollaboratorAuthor

At some point we need to figure out async effects. I don't think that should be a blocker here.

Edit: see #1090

@ArchmongerArchmonger left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I dislike the start_consumer and start_producer from an "ease of learning" perspective. Following the ReactJS philsophy of diluting the core API to nothing but the most basic interface, we should simplify everything to just send and receive.

The "easiest to learn" way to advertise this functionality would be as such:

@componentdefdemo_send_receive():
@use_messengerasyncdefconsumer(send, receive):
whileTrue:
msg=awaitreceive("my-message-type")
awaitsend("my-message-type", ...)

Just the bare concept of async cancellation, and lack of reliability for other hooks to work in a persistent way makes the user experience really awkward though.

For example, if we force the user to enable shield_cancel, we should consider whether state and set_state will always work as expected in the following scenario:

@componentdefdemo_send_receive():
state, set_state=use_state(0)
@use_messengerasyncdefconsumer(send, receive):
whileTrue:
msg=awaitmsgr.receive("my-message-type")
print(state)
set_state(msg+1)

Or alternatively, if we do not enable shield_cancel is there potentially a gap in time where the previous use_effect has been de-registered, but the new one is not registered yet?

"""Receive messages of a given type"""
send, recv = create_memory_object_stream()
self._streams.setdefault(message_type, []).append((send, recv))
async with recv:

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Would async with send, receive: not work here?

Comment on lines +148 to +149
MessengerContext(
ConnectionContext(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We can consider merging everything into a ReactPyContext.

Comment on lines +76 to +78
async def renders(self) -> AsyncIterator[_Render]:
"""Render a series of updates to a view"""

@ArchmongerArchmongerJul 7, 2023

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Why not have AsyncIterator be an accepted type on render, and then handle detection of this within layout.py?

Less APIs is generally more intuitive than more.

@Archmonger

Archmonger commented Jul 7, 2023

Copy link
Copy Markdown
Contributor

After today's tag up, two theoretical interfaces could look like this

Notes

  • Use messenger will never restart a consumer that is already running
  • We should timeout the consumer if it takes too long to teardown
  • Async function interface needs a teardown parameter in the hook

Async Function Interface

fromtypingimportCoroutineimportasynciofromreactpyimportuse_state, component, use_effectdefuse_messenger(consumer: Coroutine, timeout: int=10, teardown: Coroutine|None=None):
...
@componentdefexample():
state, set_state=use_state(0)
asyncdefconsumer_teardown(send, receive):
...
@use_messenger(timeout=20, teardown=consumer_teardown)asyncdefconsumer(receive, send, cancel: asyncio.Event):
whileTrue:
msg=awaitreceive("my-message-type")
awaitsend("my-message-type", ...)
print(state)
set_state(msg+1)
ifcancel.is_set():
break

Class Based Interface

fromtypingimportCoroutineimportasynciofromreactpyimportuse_state, component, use_effectdefuse_messenger(consumer: Coroutine, timeout: int=10):
...
@componentdefexample():
state, set_state=use_state(0)
@use_messenger(timeout=20)classConsumer:
asyncdef__call__(self, receive, send, cancel: asyncio.Event):
whileTrue:
msg=awaitreceive("my-message-type")
awaitsend("my-message-type", ...)
print(state)
set_state(msg+1)
ifcancel.is_set():
breakasyncdefteardown(self):
...

@ArchmongerArchmonger mentioned this pull request Jul 9, 2023
2 tasks
@Archmonger

Copy link
Copy Markdown
Contributor

Note: We may want to make some considerations around Django Channels Layers while developing this interface.

It's an existing ASGI messaging paradigm, so taking inspiration from their interface might not be a bad idea.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Allow server-side components to send messages to client-side components

2 participants

@rmorshea@Archmonger