-
Notifications
You must be signed in to change notification settings - Fork 3
Flow control for reactive readers #145
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
base: main
Are you sure you want to change the base?
Changes from all commits
b3cc15b
5c2e773
15b9920
d34a305
f041394
257e33d
9e59c2c
0e1f6fa
e47d1b8
8eb2e5e
ac57141
0b7df66
832c9f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # Annotate every Python parameter and return type | ||
|
|
||
| Give every parameter of every Python function and method a type | ||
| annotation, and every function a return type — including `self`-less | ||
| helpers, nested functions, and parameters whose type feels obvious | ||
| from the name. Leaving one off because `mypy` doesn't demand it is | ||
| not a reason to leave it off. | ||
|
|
||
| **Why:** `mypy` only checks a function's body once its signature is | ||
| annotated, so a single un-annotated parameter silently switches off | ||
| checking for everything that flows through it. An annotation is also | ||
| the cheapest documentation a reader gets: `stub` says nothing, | ||
| `stub: react_pb2_grpc.ReactStub` says where to look next. | ||
|
|
||
| **How to apply:** When writing or editing a `def`, annotate all of | ||
| its parameters and its return type in the same edit. When a | ||
| parameter's type is a generated protobuf message or gRPC stub, name | ||
| that type rather than falling back to `Any` — import it if the module | ||
| doesn't already. `self` and `cls` need no annotation, and neither do | ||
| `*args`/`**kwargs` when they are forwarded verbatim to an already | ||
| typed callee. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -342,6 +342,60 @@ def from_grpc_metadata(cls, metadata: GrpcMetadata) -> 'Participants': | |
| return participants | ||
|
|
||
|
|
||
| class QueryContinuations: | ||
| """Calls `React.ContinueQuery` for the responses of a `React.Query` | ||
| that a client has fully processed, which is what returns room to | ||
| that query's window. | ||
|
|
||
| Keeps at most one call in flight. While one is on its way the | ||
| client keeps processing, so the next call names only the newest | ||
| response it has handled; the server credits every response up to | ||
| that one, so nothing is lost by not naming each individually. | ||
| """ | ||
|
|
||
| def __init__( | ||
| self, | ||
| stub: react_pb2_grpc.ReactStub, | ||
| metadata: GrpcMetadata, | ||
| ) -> None: | ||
| self._stub = stub | ||
| self._metadata = metadata | ||
| self._continuation: Optional[asyncio.Task] = None | ||
|
|
||
| async def continue_past( | ||
| self, | ||
| query_response: react_pb2.QueryResponse, | ||
| ) -> None: | ||
| """Continues the query past `query_response`, if there is no | ||
| call already in flight. Raises whatever a previous call raised, | ||
| so that a failure to continue surfaces on the read.""" | ||
| if not query_response.HasField('query_id'): | ||
| # An older backend doesn't send a query ID and doesn't | ||
| # expect to be told. | ||
| return | ||
|
|
||
| if self._continuation is not None and self._continuation.done(): | ||
| await self._continuation | ||
| self._continuation = None | ||
|
|
||
| if self._continuation is None: | ||
| self._continuation = asyncio.ensure_future( | ||
| self._stub.ContinueQuery( | ||
| react_pb2.ContinueQueryRequest( | ||
| query_id=query_response.query_id, | ||
| sequence_number=query_response.sequence_number, | ||
| ), | ||
| # The same metadata ensures we're routed to the | ||
| # same server. | ||
| metadata=self._metadata, | ||
| ) | ||
| ) | ||
|
|
||
| async def stop(self) -> None: | ||
| await wait_for_tasks([self._continuation], cancel=True) | ||
| self._continuation = None | ||
|
|
||
|
|
||
| class React: | ||
| """Encapsulates machinery necessary for contexts that are "reactive", | ||
| aka, those that are initiated from calls to `React.Query` and who | ||
|
|
@@ -474,7 +528,9 @@ async def query(): | |
| self._state_type_name, self._state_ref | ||
| ) | ||
|
|
||
| call = react_pb2_grpc.ReactStub(channel).Query( | ||
| stub = react_pb2_grpc.ReactStub(channel) | ||
|
|
||
| call = stub.Query( | ||
| react_pb2.QueryRequest( | ||
| method=self._method, | ||
| request=serialized_request, | ||
|
|
@@ -497,26 +553,39 @@ async def query(): | |
| async def loop(): | ||
| assert task is not None | ||
|
|
||
| # Keep consuming however fast they arrive, | ||
| # letting each response replace the one before | ||
| # it. A reader that reads faster than this one | ||
| # can re-run therefore has its updates | ||
| # accumulated into the latest state, which is | ||
| # the one waiting here when this reader next | ||
| # asks for it. See | ||
| # https://github.com/reboot-dev/mono/issues/4754. | ||
| # | ||
| # Replacing drops the idempotency keys the | ||
| # response carried, which is deliberate: the | ||
| # keys a reactive read reports upward are the | ||
| # ones for its own state's mutations, which | ||
| # `reactively()` aggregates for it, and a | ||
| # browser observes a mutation on the query of | ||
| # the state it mutated. Keys kept here would | ||
| # reach no one. | ||
| async for query_response in call: | ||
| if not query_response.HasField('response'): | ||
| continue | ||
|
|
||
| response = self._response_type() | ||
| response.ParseFromString(query_response.response) | ||
|
|
||
| self._used_response[task].clear() | ||
|
|
||
| self._calls[task] = call | ||
| if query_response.HasField('response'): | ||
| response = self._response_type() | ||
| response.ParseFromString( | ||
| query_response.response | ||
| ) | ||
|
|
||
| self._responses[task] = asyncio.Future() | ||
| self._responses[task].set_result(response) | ||
| self._calls[task] = call | ||
|
|
||
| if not have_first_response.is_set(): | ||
| have_first_response.set() | ||
| else: | ||
| self._event.set() | ||
| self._responses[task] = asyncio.Future() | ||
| self._responses[task].set_result(response) | ||
|
|
||
| await self._used_response[task].wait() | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Okay great glad to see this gone, but just curious did you confirm with your agent that there is no reason why we were doing that in the first place? I don't think there is but it would be great to get its assessment.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Agent's assessment, as requested. Short version: no reason found for the success-path wait to exist; its one plausible job, on the error path, is kept. What it was. Where it came from. It was added in mono Reasons considered:
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. There you go 😄 |
||
| if not have_first_response.is_set(): | ||
| have_first_response.set() | ||
| else: | ||
| self._event.set() | ||
|
|
||
| raise RuntimeError('React.Query should be infinite') | ||
|
|
||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This looks like a bug, consider:
(1) First call to
continue_pastsetsself._continuationthen returns.(2) Second call to
continue_pastwhereself._continuationis not yet done so it falls off the end of the function and doesn't do anything.Maybe this never actually stalls in practice but in the degenerate case I do beleive this could cause an infinite hang.
How about a simple async function that we run that reads from a queue? Or if you want to only send the latest then it can just drain the queue after it gets awoken via
not queue.empty()andqueue.pop_nowait()and then send the last one? Or some other but still having a long-running asyncio task that is responsible for making theContinueQuerycall.