Skip to content

Commit 0f7bdb8

Browse files
committed
Experiment making echo updates a separate message
1 parent e6a816b commit 0f7bdb8

File tree

4 files changed

+44
-51
lines changed

4 files changed

+44
-51
lines changed

packages/base/src/widget.ts

Lines changed: 8 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -224,34 +224,28 @@ export class WidgetModel extends Backbone.Model {
224224
const method = data.method;
225225
switch (method) {
226226
case 'update':
227+
case 'echo_update':
227228
this.state_change = this.state_change
228229
.then(() => {
229-
// TODO: we can either combine state before replacing buffers, or we
230-
// can introduce a new echo_state buffer path.
231230
const state = data.state;
232231
const buffer_paths = data.buffer_paths ?? [];
233-
const buffers = msg.buffers?.slice(0,buffer_paths.length) ?? [];
232+
const buffers = msg.buffers?.slice(0, buffer_paths.length) ?? [];
234233
utils.put_buffers(state, buffer_paths, buffers);
235234

236-
const echo_state = data.echo_state;
237-
const echo_buffer_paths = data.echo_buffer_paths ?? [];
238-
const echo_buffers = msg.buffers?.slice(buffer_paths.length) ?? [];
239-
utils.put_buffers(echo_state, echo_buffer_paths, echo_buffers);
240-
241-
if (msg.parent_header && data.echo_state) {
235+
if (msg.parent_header && method === 'echo_update') {
242236
const msgId = (msg.parent_header as any).msg_id;
243237
// we may have echos coming from other clients, we only care about
244238
// dropping echos for which we expected a reply
245-
const expectedEcho = Object.keys(data.echo_state).filter(
246-
(attrName) => this.expectedEchoMsgIds.has(attrName)
239+
const expectedEcho = Object.keys(state).filter((attrName) =>
240+
this.expectedEchoMsgIds.has(attrName)
247241
);
248242
expectedEcho.forEach((attrName: string) => {
249243
// Skip echo messages until we get the reply we are expecting.
250244
const isOldMessage =
251245
this.expectedEchoMsgIds.get(attrName) !== msgId;
252246
if (isOldMessage) {
253247
// Ignore an echo update that comes before our echo.
254-
delete echo_state[attrName];
248+
delete state[attrName];
255249
} else {
256250
// we got our echo confirmation, so stop looking for it
257251
this.expectedEchoMsgIds.delete(attrName);
@@ -263,14 +257,14 @@ export class WidgetModel extends Backbone.Model {
263257
attrName
264258
)
265259
) {
266-
delete echo_state[attrName];
260+
delete state[attrName];
267261
}
268262
}
269263
});
270264
}
271265
return (this.constructor as typeof WidgetModel)._deserialize_state(
272266
// Combine the state updates, with preference for kernel updates
273-
{ ...echo_state, ...state },
267+
state,
274268
this.widget_manager
275269
);
276270
})

packages/schema/messages.md

Lines changed: 8 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -292,35 +292,31 @@ The `data.state` and `data.buffer_paths` values are the same as in the `comm_ope
292292

293293
See the [Model state](jupyterwidgetmodels.latest.md) documentation for the attributes of core Jupyter widgets.
294294

295-
#### Synchronizing multiple frontends: `update` with `echo_state`
295+
#### Synchronizing multiple frontends: `echo_update`
296296

297-
Starting with protocol version `2.1.0`, `update` messages from the kernel to the frontend can have optional `echo_state` and `echo_buffer_paths` attributes. These are analogous to `state` and `buffer_paths` attributes, but are for echoing state in messages from a frontend to the kernel back out to all the frontends.
297+
Starting with protocol version `2.1.0`, `echo_update` messages from the kernel to the frontend are optional update messages for echoing state in messages from a frontend to the kernel back out to all the frontends.
298298

299299
```
300300
{
301301
'comm_id' : 'u-u-i-d',
302302
'data' : {
303-
'method': 'update',
303+
'method': 'echo_update',
304304
'state': { <dictionary of widget state> },
305305
'buffer_paths': [ <list with paths corresponding to the binary buffers> ]
306-
'echo_state': { <dictionary of widget state> },
307-
'echo_buffer_paths': [ <list with paths corresponding to the binary buffers> ]
308306
}
309307
}
310308
```
311309

312-
The Jupyter comm protocol is asymmetric in how messages flow: messages flow from a single frontend to a single kernel, but messages are broadcast from the kernel to *all* frontends. In the widget protocol, if a frontend updates the value of a widget, the frontend does not have a way to directly notify other frontends about the state update. The `echo_state` and `echo_buffer_paths` optional attributes enable a kernel to broadcast out frontend updates to all frontends. This can also help resolve the race condition where the kernel and a frontend simultaneously send updates to each other since the frontend now knows the order of kernel updates.
310+
The Jupyter comm protocol is asymmetric in how messages flow: messages flow from a single frontend to a single kernel, but messages are broadcast from the kernel to *all* frontends. In the widget protocol, if a frontend updates the value of a widget, the frontend does not have a way to directly notify other frontends about the state update. The `echo_update` optional messages enable a kernel to broadcast out frontend updates to all frontends. This can also help resolve the race condition where the kernel and a frontend simultaneously send updates to each other since the frontend now knows the order of kernel updates.
313311

314-
These attributes are intended to be used as follows:
312+
The `echo_update` messages enable a frontend to optimistically update its widget views to reflect its own changes that it knows the kernel will yet process. These messages are intended to be used as follows:
315313
1. A frontend model attribute is updated, and the frontend views are optimistically updated to reflect the attribute.
316314
2. The frontend queues an update message to the kernel and records the message id for the attribute.
317-
3. The frontend ignores updates to the attribute from the kernel contained in `echo_state` fields, until it gets an `echo_state` update corresponding to its own update (i.e., the [parent_header](https://jupyter-client.readthedocs.io/en/latest/messaging.html#parent-header) id matches the stored message id for the attribute). It also ignores `echo_state` updates if it has a pending attribute update to send to the kernel.
318-
319-
Since the `echo_state` attributes are optional, and not all attributes may be echoed, it is important that only `echo_state` updates are ignored in the last step above, and `state` updates are always applied.
315+
3. The frontend ignores updates to the attribute from the kernel contained in `echo_update` messages until it gets an `echo_update` message corresponding to its own update of the attribute (i.e., the [parent_header](https://jupyter-client.readthedocs.io/en/latest/messaging.html#parent-header) id matches the stored message id for the attribute). It also ignores `echo_update` updates if it has a pending attribute update to send to the kernel. Once the frontend receives its own `echo_update` and does not have any more pending attribute updates to send to the kernel, it starts applying attribute updates from `echo_update` messages.
320316

321-
For situations where sending back an echo update for an attribute is considered too expensive, we have implemented an opt-out mechanism in ipywidgets. A trait can have the `no_echo` metadata attribute to flag that the kernel should not send back an update to the frontends. We suggest other implementations implement a similar opt-out mechanism.
317+
Since the `echo_update` update messages are optional, and not all attribute updates may be echoed, it is important that only `echo_update` updates are ignored in the last step above, and `update` message updates are always applied.
322318

323-
TODO: at this point, should we just make this an `'method': 'echo_update'` message. Then the entire message is ignored by older implementations, and processing is almost exactly the same for implementations that do implement it - they just have one or two small changes based on the message type.
319+
For attributes where sending back an `echo_update` is considered too expensive or unnecessary, we have implemented an opt-out mechanism in the ipywidgets package. A model trait can have the `no_echo` metadata attribute to flag that the kernel should not send an `echo_update` update for that attribute to the frontends. We suggest other implementations implement a similar opt-out mechanism.
324320

325321
#### State requests: `request_state`
326322

python/ipywidgets/ipywidgets/widgets/tests/test_set_state.py

Lines changed: 25 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -90,12 +90,18 @@ def test_set_state_transformer():
9090
))
9191
# Since the deserialize step changes the state, this should send an update
9292
assert w.comm.messages == [((), dict(
93+
buffers=[],
94+
data=dict(
95+
buffer_paths=[],
96+
method='echo_update',
97+
state=dict(d=[True, False, True]),
98+
))),
99+
((), dict(
93100
buffers=[],
94101
data=dict(
95102
buffer_paths=[],
96103
method='update',
97-
state=dict(),
98-
echo_state=dict(d=[False, True, False]),
104+
state=dict(d=[False, True, False]),
99105
)))]
100106

101107

@@ -117,15 +123,14 @@ def test_set_state_data_truncate():
117123
d={'data': data},
118124
))
119125
# Get message for checking
120-
assert len(w.comm.messages) == 1 # ensure we didn't get more than expected
121-
msg = w.comm.messages[0]
126+
assert len(w.comm.messages) == 2 # ensure we didn't get more than expected
127+
msg = w.comm.messages[1]
122128
# Assert that the data update (truncation) sends an update
123129
buffers = msg[1].pop('buffers')
124130
assert msg == ((), dict(
125131
data=dict(
126132
method='update',
127-
state=dict(),
128-
echo_state=dict(d={}, a=True),
133+
state=dict(d={}),
129134
buffer_paths=[['d', 'data']]
130135
)))
131136

@@ -181,8 +186,8 @@ def test_set_state_cint_to_float():
181186
ci = 5.6
182187
))
183188
# Ensure an update message gets produced
184-
assert len(w.comm.messages) == 1
185-
msg = w.comm.messages[0]
189+
assert len(w.comm.messages) == 2
190+
msg = w.comm.messages[1]
186191
data = msg[1]['data']
187192
assert data['method'] == 'update'
188193
assert data['state'] == {'ci': 5}
@@ -265,16 +270,13 @@ def _propagate_value(self, change):
265270
assert widget.value == 2
266271
assert widget.other == 11
267272

268-
# we expect only single state to be sent, i.e. the {'value': 42.0} state
269-
270-
# TODO: in this case, the echo_state key needs to be set, but we want the
271-
# value to come from state. Since we do not want to duplicate the
272-
# potentially large value, we just set the echo_state key to null. The
273-
# frontend must rank the 'state' value higher than the echo_state value.
274-
msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': [], 'echo_state': {'value': None}}
273+
msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []}
275274
call42 = mock.call(msg, buffers=[])
276275

277-
calls = [call42]
276+
msg = {'method': 'update', 'state': {'value': 2.0, 'other': 11.0}, 'buffer_paths': []}
277+
call2 = mock.call(msg, buffers=[])
278+
279+
calls = [call42, call2]
278280
widget._send.assert_has_calls(calls)
279281

280282

@@ -293,7 +295,7 @@ class ValueWidget(Widget):
293295
assert widget.value == 42
294296

295297
# we expect this to be echoed
296-
msg = {'method': 'update', 'state': {}, 'echo_state': {'value': 42.0}, 'buffer_paths': []}
298+
msg = {'method': 'echo_update', 'state': {'value': 42.0}, 'buffer_paths': []}
297299
call42 = mock.call(msg, buffers=[])
298300

299301
calls = [call42]
@@ -329,10 +331,14 @@ def _square(self, change):
329331

330332
# we expect this to be echoed
331333
# note that only value is echoed, not square
332-
msg = {'method': 'update', 'state': {'square': 64}, 'echo_state': {'value': 8.0}, 'buffer_paths': []}
334+
msg = {'method': 'echo_update', 'state': {'value': 8.0}, 'buffer_paths': []}
333335
call = mock.call(msg, buffers=[])
336+
337+
msg = {'method': 'update', 'state': {'square': 64}, 'buffer_paths': []}
338+
call2 = mock.call(msg, buffers=[])
339+
334340

335-
calls = [call]
341+
calls = [call, call2]
336342
widget._send.assert_has_calls(calls)
337343

338344

@@ -363,5 +369,4 @@ class ValueWidget(Widget):
363369

364370
# a regular set should sync to the frontend
365371
widget.value = 43
366-
# TODO: the original test had a value in echo, which I think is incorrect - no value should be sent back to the frontend
367372
widget._send.assert_has_calls([mock.call({'method': 'update', 'state': {'value': 43.0}, 'buffer_paths': []}, buffers=[])])

python/ipywidgets/ipywidgets/widgets/widget.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -572,11 +572,9 @@ def set_state(self, sync_data):
572572
if echo_state:
573573
echo_state, echo_buffer_paths, echo_buffers = _remove_buffers(echo_state)
574574
msg = {
575-
'method': 'update',
576-
'state': {},
577-
'buffer_paths': [],
578-
'echo_state': echo_state,
579-
'echo_buffer_paths': echo_buffer_paths
575+
'method': 'echo_update',
576+
'state': echo_state,
577+
'buffer_paths': echo_buffer_paths,
580578
}
581579
self._send(msg, buffers=echo_buffers)
582580

0 commit comments

Comments
 (0)