Skip to content
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

feat(ui): Only send new exported objects #129

Merged
merged 11 commits into from
Dec 13, 2023
36 changes: 17 additions & 19 deletions plugins/ui/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -1438,13 +1438,13 @@ use_table_listener(

###### Parameters

| Parameter | Type | Description |
|---------------|--------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `table` | `Table` | The table to listen to. |
| `listener` | `Callable[[TableUpdate, bool], None] \| TableListener` | Either a function or a [TableListener](https://deephaven.io/core/pydoc/code/deephaven.table_listener.html#deephaven.table_listener.TableListener) with an on_update function. The function must take a [TableUpdate](https://deephaven.io/core/pydoc/code/deephaven.table_listener.html#deephaven.table_listener.TableUpdate) and is_replay bool. [More table listener info](https://deephaven.io/core/docs/how-to-guides/table-listeners-python/) |
| `description` | `str \| None` | An optional description for the UpdatePerformanceTracker to append to the listener’s entry description, default is None.
| `do_replay` | `bool` | Whether to replay the initial snapshot of the table, default is False. |
| `replay_lock` | `LockType` | The lock type used during replay, default is ‘shared’, can also be ‘exclusive’. |
| Parameter | Type | Description |
| ------------- | ------------------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `table` | `Table` | The table to listen to. |
| `listener` | `Callable[[TableUpdate, bool], None] \| TableListener` | Either a function or a [TableListener](https://deephaven.io/core/pydoc/code/deephaven.table_listener.html#deephaven.table_listener.TableListener) with an on_update function. The function must take a [TableUpdate](https://deephaven.io/core/pydoc/code/deephaven.table_listener.html#deephaven.table_listener.TableUpdate) and is_replay bool. [More table listener info](https://deephaven.io/core/docs/how-to-guides/table-listeners-python/) |
| `description` | `str \| None` | An optional description for the UpdatePerformanceTracker to append to the listener’s entry description, default is None. |
| `do_replay` | `bool` | Whether to replay the initial snapshot of the table, default is False. |
| `replay_lock` | `LockType` | The lock type used during replay, default is ‘shared’, can also be ‘exclusive’. |

##### use_table_data

Expand Down Expand Up @@ -1637,7 +1637,6 @@ class LinkPoint(TypedDict):




#### Context

By default, the context of a `@ui.component` will be created per client session (same as [Parameterized Query's "parallel universe" today](https://github.com/deephaven-ent/iris/blob/868b868fc9e180ee948137b10b6addbac043605e/ParameterizedQuery/src/main/java/io/deephaven/query/parameterized/impl/ParameterizedQueryServerImpl.java#L140)). However, it would be interesting if it were possible to share a context among all sessions for the current user, and/or share a context with other users even; e.g. if one user selects and applies a filter, it updates immediately for all other users with that dashboard open. So three cases:
Expand Down Expand Up @@ -1772,15 +1771,15 @@ sequenceDiagram
UIP->>SP: Render tft
SP->>SP: Run sym_exchange
Note over SP: sym_exchange executes, running text_filter_table twice
SP-->>UIP: Result (flex([tft1, tft2]))
UIP-->>W: Display (flex([tft1, tft2]))
SP-->>UIP: Result (document=flex([tft1, tft2]), exported_objects=[tft1, tft2])
UIP-->>W: Display Result

U->>UIP: Change text input 1
UIP->>SP: Change state
SP->>SP: Run sym_exchange
Note over SP: sym_exchange executes, text_filter_table only <br/>runs once for the one changed input
SP-->>UIP: Result (flex([tft1', tft2]))
UIP-->>W: Display (flex([tft1', tft2]))
Note over SP: sym_exchange executes, text_filter_table only <br/>runs once for the one changed input<br/>only exports the new table, as client already has previous tables
SP-->>UIP: Result (document=flex([tft1', tft2], exported_objects=[tft1']))
UIP-->>W: Display Result
```

##### Communication/Callbacks
Expand All @@ -1807,12 +1806,11 @@ sequenceDiagram

A component that is created on the server side runs through a few steps before it is rendered on the client side:

1. Element - The basis for all UI components. Generally a `FunctionElement`, and does not run the function until it is requested by the UI. The result can change depending on the context that it is rendered in (e.g. what "state" is set).
2. RenderedNode - After an element has been rendered using a renderer, it becomes a `RenderedNode`. This is an immutable representation of the document.
3. JSONEncodedNode - The `RenderedNode` is then encoded into JSON using `NodeEncoder`. It pulls out all the objects and maps them to exported objects, and all the callables to be mapped to commands that can be accepted by JSON-RPC. This is the final representation of the document that is sent to the client.
4. ElementPanel - Client side where it's receiving the `documentUpdated` from the server plugin, and then rendering the `JSONEncodedNode` into a `ElementPanel` (e.g. a `GoldenLayout` panel). Decodes the JSON, maps all the exported objects to the actual objects, and all the callables to async methods that will call to the server.
5. ElementView - Renders the decoded panel into the UI. Picks the element based on the name of it.
6. ObjectView - Render an exported object
1. [Element](./src/deephaven/ui/elements/Element.py) - The basis for all UI components. Generally a [FunctionElement](./src/deephaven/ui/elements/FunctionElement.py) created by a script using the [@ui.component](./src/deephaven/ui/components/make_component.py) decorator, and does not run the function until it is rendered. The result can change depending on the context that it is rendered in (e.g. what "state" is set).
2. [ElementMessageStream](./src/deephaven/ui/object_types/ElementMessageStream.py) - The `ElementMessageStream` is responsible for rendering one instance of an element in a specific rendering context and handling the server-client communication. The element is rendered to create a [RenderedNode](./src/deephaven/ui/renderer/RenderedNode.py), which is an immutable representation of a rendered document. The `RenderedNode` is then encoded into JSON using [NodeEncoder](./src/deephaven/ui/renderer/NodeEncoder.py), which pulls out all the non-serializable objects (such as Tables) and maps them to exported objects, and all the callables to be mapped to commands that can be accepted by JSON-RPC. This is the final representation of the document that is sent to the client, and ultimately handled by the `WidgetHandler`.
3. [DashboardPlugin](./src/js/src/DashboardPlugin.tsx) - Client side `DashboardPlugin` that listens for when a widget of type `Element` is opened, and manage the `WidgetHandler` instances that are created for each widget.
4. [WidgetHandler](./src/js/src/WidgetHandler.tsx) - Uses JSON-RPC communication with an `ElementMessageStream` instance to load the initial rendered document and associated exported objects. Listens for any changes and updates the document accordingly.
5. [DocumentHandler](./src/js/src/DocumentHandler.tsx) - Handles the root of a rendered document, laying out the appropriate panels or dashboard specified.

#### Other Decisions

Expand Down
9 changes: 7 additions & 2 deletions plugins/ui/src/deephaven/ui/components/make_component.py
Original file line number Diff line number Diff line change
@@ -1,18 +1,23 @@
import functools
import logging
from typing import Any, Callable
from .._internal import get_component_qualname
from ..elements import FunctionElement

logger = logging.getLogger(__name__)


def make_component(func):
def make_component(func: Callable[..., Any]):
"""
Create a FunctionalElement from the passed in function.

Args:
func: The function to create a FunctionalElement from.
Runs when the component is being rendered.
"""

@functools.wraps(func)
def make_component_node(*args, **kwargs):
def make_component_node(*args: Any, **kwargs: Any):
component_type = get_component_qualname(func)

return FunctionElement(component_type, lambda: func(*args, **kwargs))
Expand Down
6 changes: 4 additions & 2 deletions plugins/ui/src/deephaven/ui/elements/Element.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
from __future__ import annotations

from abc import ABC, abstractmethod
from typing import Any
from typing import Any, Dict
from .._internal import RenderContext

PropsType = Dict[str, Any]


class Element(ABC):
"""
Expand All @@ -21,7 +23,7 @@ def name(self) -> str:
return "deephaven.ui.Element"

@abstractmethod
def render(self, context: RenderContext) -> dict[str, Any]:
def render(self, context: RenderContext) -> PropsType:
"""
Renders this element, and returns the result as a dictionary of props for the element.
If you just want to render children, pass back a dict with children only, e.g. { "children": ... }
Expand Down
4 changes: 2 additions & 2 deletions plugins/ui/src/deephaven/ui/elements/__init__.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from .Element import Element
from .Element import Element, PropsType
from .BaseElement import BaseElement
from .FunctionElement import FunctionElement
from .UITable import UITable

__all__ = ["BaseElement", "Element", "FunctionElement", "UITable"]
__all__ = ["BaseElement", "Element", "FunctionElement", "PropsType", "UITable"]
66 changes: 47 additions & 19 deletions plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
from __future__ import annotations
import json
import io
import json
from jsonrpc import JSONRPCResponseManager, Dispatcher
import logging
from typing import Any
Expand All @@ -14,6 +14,36 @@


class ElementMessageStream(MessageStream):
_manager: JSONRPCResponseManager
"""
Handle incoming requests from the client.
"""

_dispatcher: Dispatcher
"""
The dispatcher to use when client calls callables.
"""

_encoder: NodeEncoder
"""
Encoder to use to encode the document.
"""

_message_id: int
"""
The next message ID to use.
"""

_element: Element
"""
The element to render.
"""

_connection: MessageStream
"""
The connection to send the rendered element to.
"""

def __init__(self, element: Element, connection: MessageStream):
"""
Create a new ElementMessageStream. Renders the element in a render context, and sends the rendered result to the
Expand All @@ -25,10 +55,10 @@ def __init__(self, element: Element, connection: MessageStream):
"""
self._element = element
self._connection = connection
self._update_count = 0
self._message_id = 0
self._manager = JSONRPCResponseManager()
self._dispatcher = Dispatcher()
self._encoder = NodeEncoder(separators=(",", ":"))

def start(self) -> None:
context = RenderContext()
Expand All @@ -54,15 +84,15 @@ def on_data(self, payload: bytes, references: list[Any]) -> None:
if response is None:
return

payload = response.json
logger.debug("Response: %s, %s", type(payload), payload)
self._connection.on_data(payload.encode(), [])
response_payload = response.json
logger.debug("Response: %s, %s", type(response_payload), response_payload)
self._connection.on_data(response_payload.encode(), [])

def _get_next_message_id(self) -> int:
self._message_id += 1
return self._message_id

def _make_notification(self, method: str, *params: list[Any]) -> None:
def _make_notification(self, method: str, *params: Any) -> dict[str, Any]:
"""
Make a JSON-RPC notification. Can notify the client without expecting a response.

Expand All @@ -76,7 +106,7 @@ def _make_notification(self, method: str, *params: list[Any]) -> None:
"params": params,
}

def _make_request(self, method: str, *params: list[Any]) -> None:
def _make_request(self, method: str, *params: Any) -> dict[str, Any]:
"""
Make a JSON-RPC request. Messages the client and expects a response.

Expand All @@ -98,22 +128,20 @@ def _send_document_update(self, root: RenderedNode) -> None:
Args:
root: The root node of the document to send
"""
# We use an ID prefix to ensure that the callable ids are unique across each document render/update
# That way we don't have to worry about callables from previous renders being called accidentally
self._update_count += 1
id_prefix = f"cb_{self._update_count}_"

# TODO(#67): Send a diff of the document instead of the entire document.
request = self._make_notification("documentUpdated", root)
encoder = NodeEncoder(callable_id_prefix=id_prefix, separators=(",", ":"))
payload = encoder.encode(request)
encoder_result = self._encoder.encode_node(root)
encoded_document = encoder_result["encoded_node"]
new_objects = encoder_result["new_objects"]
callable_id_dict = encoder_result["callable_id_dict"]

request = self._make_notification("documentUpdated", encoded_document)
payload = json.dumps(request)
logger.debug(f"Sending payload: {payload}")

dispatcher = Dispatcher()
for i, callable in enumerate(encoder.callables):
key = f"{id_prefix}{i}"
logger.debug("Registering callable %s", key)
dispatcher[key] = callable
for callable, callable_id in callable_id_dict.items():
logger.debug("Registering callable %s", callable_id)
dispatcher[callable_id] = callable
self._dispatcher = dispatcher
self._connection.on_data(payload.encode(), encoder.objects)
self._connection.on_data(payload.encode(), new_objects)
Loading