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: delta updates #1069

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ repos:
sphinx,
click,
watchdog,
pyjsonpatch,
]
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.2.2
Expand Down
13 changes: 13 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions plugins/ui/setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ install_requires =
deephaven-core>=0.37.0
deephaven-plugin>=0.6.0
json-rpc
pyjsonpatch
Comment on lines 30 to +31
Copy link
Member

Choose a reason for hiding this comment

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

We should pin json-rpc and pyjsonpatch. Right now if either of these libraries update with a breaking change, users with new installs may not work correctly.

Suggested change
json-rpc
pyjsonpatch
json-rpc>=1.15.0
pyjsonpatch>=0.1.1

Docs on compatible release notation: https://peps.python.org/pep-0440/#compatible-release

deephaven-plugin-utilities>=0.0.2
typing_extensions;python_version<'3.11'
include_package_data = True
Expand Down
17 changes: 10 additions & 7 deletions plugins/ui/src/deephaven/ui/object_types/ElementMessageStream.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from deephaven.server.executors import submit_task
from deephaven.execution_context import ExecutionContext, get_exec_ctx
from deephaven.liveness_scope import liveness_scope
from pyjsonpatch import generate_patch

from .._internal import wrap_callable
from ..elements import Element
Expand Down Expand Up @@ -194,6 +195,7 @@ def __init__(self, element: Element, connection: MessageStream):
self._render_state = _RenderState.IDLE
self._exec_context = get_exec_ctx()
self._is_closed = False
self._last_document = {}

def _render(self) -> None:
logger.debug("ElementMessageStream._render")
Expand All @@ -208,7 +210,7 @@ def _render(self) -> None:
try:
node = self._renderer.render(self._element)
state = self._context.export_state()
self._send_document_update(node, state)
self._send_document_patch(node, state)
except Exception as e:
# Send the error to the client for displaying to the user
# If there's an error sending it to the client, then it will be caught by the render exception handler
Expand Down Expand Up @@ -450,11 +452,11 @@ def _close_callable(self, callable_id: str) -> None:
self._callable_dict.pop(callable_id, None)
self._temp_callable_dict.pop(callable_id, None)

def _send_document_update(
def _send_document_patch(
self, root: RenderedNode, state: ExportedRenderState
) -> None:
"""
Send a document update to the client. Currently just sends the entire document for each update.
Send a document update to the client in the form of a JSON Patch (RFC 6902).

Args:
root: The root node of the document to send
Expand All @@ -464,18 +466,19 @@ def _send_document_update(
logger.error("Stream is closed, cannot render document")
sys.exit()

# TODO(#67): Send a diff of the document instead of the entire document.
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"]

document = json.loads(encoded_document)
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should see if we can do this before the node is stringified. Or is this necessary? Just don't want an unnecessary stringify -> parse -> stringify on the server

patch = generate_patch(self._last_document, document)
self._last_document = document

logger.debug("Exported state: %s", state)
encoded_state = json.dumps(state)

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

Expand Down
1 change: 1 addition & 0 deletions plugins/ui/src/js/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@
"@fortawesome/react-fontawesome": "^0.2.0",
"@internationalized/date": "^3.5.5",
"classnames": "^2.5.1",
"fast-json-patch": "^3.1.1",
"json-rpc-2.0": "^1.6.0",
"nanoid": "^5.0.7",
"react-markdown": "^8.0.7",
Expand Down
20 changes: 12 additions & 8 deletions plugins/ui/src/js/src/widget/WidgetHandler.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,13 @@ import { act, render } from '@testing-library/react';
import { useWidget } from '@deephaven/jsapi-bootstrap';
import { dh } from '@deephaven/jsapi-types';
import { TestUtils } from '@deephaven/test-utils';
import { Operation } from 'fast-json-patch';
import WidgetHandler, { WidgetHandlerProps } from './WidgetHandler';
import { DocumentHandlerProps } from './DocumentHandler';
import {
makeWidget,
makeWidgetDescriptor,
makeWidgetEventDocumentUpdated,
makeWidgetEventDocumentPatched,
Copy link
Collaborator

Choose a reason for hiding this comment

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

Idk if it's worth changing the name of this event on client/server from update to patch. Update seems fine still. Just reduces the size of the PR since there is no separate update action. Update might even be a clearer name to describe what is happening since patched could imply "fixed"

@mofojed thoughts? I know it's a bit pedantic and I'm fine leaving it as patch. Just thought I'd mention it

makeWidgetEventJsonRpcResponse,
} from './WidgetTestUtils';

Expand Down Expand Up @@ -68,6 +69,7 @@ it('updates the document when event is received', async () => {
const mockSendMessage = jest.fn();
const initialData = { state: { fiz: 'baz' } };
const initialDocument = { foo: 'bar' };
const initialPatch: Operation[] = [{ op: 'add', path: '/foo', value: 'bar' }];
mockWidgetWrapper = {
widget: makeWidget({
addEventListener: mockAddEventListener,
Expand Down Expand Up @@ -102,7 +104,7 @@ it('updates the document when event is received', async () => {
listener(makeWidgetEventJsonRpcResponse(1));

// Then send the initial document update
listener(makeWidgetEventDocumentUpdated(initialDocument));
listener(makeWidgetEventDocumentPatched(initialPatch));
});

expect(mockDocumentHandler).toHaveBeenCalledWith(
Expand All @@ -115,13 +117,13 @@ it('updates the document when event is received', async () => {

mockDocumentHandler.mockClear();

const updatedDocument = { fiz: 'baz' };

const updatedDocument = { foo: 'bar', fiz: 'baz' };
const updatedPatch: Operation[] = [{ op: 'add', path: '/fiz', value: 'baz' }];
act(() => {
// Send an updated document event to the listener of the widget
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(mockAddEventListener.mock.calls[0] as any)[1](
makeWidgetEventDocumentUpdated(updatedDocument)
makeWidgetEventDocumentPatched(updatedPatch)
);
});
expect(mockDocumentHandler).toHaveBeenCalledWith(
Expand All @@ -144,6 +146,7 @@ it('updates the initial data only when widget has changed', async () => {
const onClose = jest.fn();
const data1 = { state: { fiz: 'baz' } };
const document1 = { foo: 'bar' };
const patch1: Operation[] = [{ op: 'add', path: '/foo', value: 'bar' }];
mockWidgetWrapper = {
widget: makeWidget({
addEventListener,
Expand Down Expand Up @@ -181,7 +184,7 @@ it('updates the initial data only when widget has changed', async () => {
listener(makeWidgetEventJsonRpcResponse(1));

// Then send the initial document update
listener(makeWidgetEventDocumentUpdated(document1));
listener(makeWidgetEventDocumentPatched(patch1));
});

expect(mockDocumentHandler).toHaveBeenCalledWith(
Expand Down Expand Up @@ -210,7 +213,8 @@ it('updates the initial data only when widget has changed', async () => {
expect(sendMessage).not.toHaveBeenCalled();

const widget2 = makeWidgetDescriptor();
const document2 = { FOO: 'BAR' };
const document2 = { foo: 'bar', FOO: 'BAR' };
const patch2: Operation[] = [{ op: 'add', path: '/FOO', value: 'BAR' }];
Copy link
Collaborator

Choose a reason for hiding this comment

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

We should test other operations too. I'm assuming there's remove and modify/change at least

mockWidgetWrapper = {
widget: makeWidget({
addEventListener,
Expand Down Expand Up @@ -253,7 +257,7 @@ it('updates the initial data only when widget has changed', async () => {
listener(makeWidgetEventJsonRpcResponse(1));

// Then send the initial document update
listener(makeWidgetEventDocumentUpdated(document2));
listener(makeWidgetEventDocumentPatched(patch2));
});

expect(mockDocumentHandler).toHaveBeenCalledWith(
Expand Down
Loading
Loading