Skip to content

Commit abde7fb

Browse files
committed
Allow garcefull subscripiton cancellation
This commit prevents an error is propagated to the data server if a subcription is disconnected intentionnally without an error.
1 parent d442b1a commit abde7fb

File tree

5 files changed

+84
-55
lines changed

5 files changed

+84
-55
lines changed

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
* `subscribe` accepts keyword arguments, which are forwarded to the data-server.
55
This allows to configure the subscription to the data-server.
66
Note that as of LabOne 24.10, no node supports yet subscription configuration.
7+
* Fix error message in data server log if a subscription is cancelled gracefully.
8+
* Adapt mock data server to hand unsubscribe events correctly.
79

810
## Version 3.1.2
911
* Fix bug which caused streaming errors to cancel the subscriptions

src/labone/core/subscription.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -485,8 +485,7 @@ def _distribute_to_data_queues(
485485
value: The value to add to the data queue.
486486
487487
Raises:
488-
capnp.KjException: If no data queues are registered any more and
489-
the subscription should be removed.
488+
ValueError: If the value could not be parsed.
490489
"""
491490
try:
492491
parsed_value = self._parser_callback(AnnotatedValue.from_capnp(value))
@@ -510,10 +509,6 @@ def _distribute_to_data_queues(
510509
raise
511510
self.distribute_to_data_queues(parsed_value)
512511

513-
if not self._data_queues:
514-
msg = "No data queues are registered anymore. Disconnecting subscription."
515-
raise errors.StreamingError(msg)
516-
517512
async def capnp_callback(
518513
self,
519514
interface: int, # noqa: ARG002
@@ -531,13 +526,13 @@ async def capnp_callback(
531526
method_index: The method index of the capnp schema.
532527
call_input: The input data of the capnp schema.
533528
fulfiller: The fulfiller to fulfill the promise.
534-
535-
Raises:
536-
capnp.KjException: If no data queues are registered any more and
537-
the subscription should be removed.
538529
"""
539530
try:
540531
list(map(self._distribute_to_data_queues, call_input.values))
532+
if len(self._data_queues) == 0:
533+
msg = "No queues registered anymore"
534+
fulfiller.reject(zhinst.comms.Fulfiller.DISCONNECTED, msg)
535+
return
541536
fulfiller.fulfill()
542537
except Exception as err: # noqa: BLE001
543538
fulfiller.reject(zhinst.comms.Fulfiller.FAILED, err.args[0])

src/labone/mock/automatic_server.py

Lines changed: 45 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
1-
"""Partially predifined behaviour for HPK mock.
1+
"""Partially predefined behavior for HPK mock.
22
33
This class provides basic Hpk mock functionality by taking over some usually
44
desired tasks. With that in place, the user may inherit from this class
55
in order to further specify behavior, without having to start from scratch.
6-
Even if some of the predefined behaviour is not desired, the implementation
6+
Even if some of the predefined behavior is not desired, the implementation
77
can give some reference on how an individual mock server can be implemented.
88
99
10-
Already predefined behaviour:
10+
Already predefined behavior:
1111
1212
* Simulating state for get/set:
1313
A dictionary is used to store the state of the mock server.
@@ -22,7 +22,7 @@
2222
The subscriptions are stored and on every change, the new value is passed
2323
into the queues.
2424
* Adding chronological timestamps to responses:
25-
The server answers need timestamps to the responsis in any case.
25+
The server answers need timestamps to the responses in any case.
2626
By using the monotonic clock, the timestamps are added automatically.
2727
2828
"""
@@ -95,7 +95,7 @@ def __init__(
9595
self._common_prefix = None
9696

9797
def get_timestamp(self) -> int:
98-
"""Create a realisitc timestamp.
98+
"""Create a realistic timestamp.
9999
100100
Call this function to obtain a timestamp for some response.
101101
As a internal clock is used, subsequent calls will return
@@ -107,15 +107,15 @@ def get_timestamp(self) -> int:
107107
return time.monotonic_ns()
108108

109109
def _sanitize_path(self, path: LabOneNodePath) -> LabOneNodePath:
110-
"""Sanatize the path.
110+
"""Sanitize the path.
111111
112112
Removes trailing slashes and replaces empty path with root path.
113113
114114
Args:
115-
path: Path to sanatize.
115+
path: Path to sanitize.
116116
117117
Returns:
118-
Sanatized path.
118+
Sanitized path.
119119
"""
120120
if self._common_prefix and not path.startswith("/"):
121121
return f"{self._common_prefix}/{path}"
@@ -127,19 +127,19 @@ async def list_nodes_info(
127127
*,
128128
flags: ListNodesInfoFlags | int = ListNodesInfoFlags.ALL, # noqa: ARG002
129129
) -> dict[LabOneNodePath, NodeInfoType]:
130-
"""Predefined behaviour for list_nodes_info.
130+
"""Predefined behavior for list_nodes_info.
131131
132132
Uses knowledge of the tree structure to answer.
133133
134134
Warning:
135135
Flags will be ignored in this implementation. (TODO)
136-
For now, the behaviour is equivalent to
136+
For now, the behavior is equivalent to
137137
ListNodesFlags.RECURSIVE | ListNodesFlags.ABSOLUTE
138138
139139
Args:
140140
path: Path to narrow down which nodes should be listed. Omitting
141141
the path will list all nodes by default.
142-
flags: Flags to control the behaviour of the list_nodes_info method.
142+
flags: Flags to control the behavior of the list_nodes_info method.
143143
144144
Returns:
145145
Dictionary of paths to node info.
@@ -154,19 +154,19 @@ async def list_nodes(
154154
*,
155155
flags: ListNodesFlags | int = ListNodesFlags.ABSOLUTE, # noqa: ARG002
156156
) -> list[LabOneNodePath]:
157-
"""Predefined behaviour for list_nodes.
157+
"""Predefined behavior for list_nodes.
158158
159159
Uses knowledge of the tree structure to answer.
160160
161161
Warning:
162162
Flags will be ignored in this implementation. (TODO)
163-
For now, the behaviour is equivalent to
163+
For now, the behavior is equivalent to
164164
ListNodesFlags.RECURSIVE | ListNodesFlags.ABSOLUTE
165165
166166
Args:
167167
path: Path to narrow down which nodes should be listed. Omitting
168168
the path will list all nodes by default.
169-
flags: Flags to control the behaviour of the list_nodes method.
169+
flags: Flags to control the behavior of the list_nodes method.
170170
171171
Returns:
172172
List of paths.
@@ -183,7 +183,7 @@ async def list_nodes(
183183
]
184184

185185
async def get(self, path: LabOneNodePath) -> AnnotatedValue:
186-
"""Predefined behaviour for get.
186+
"""Predefined behavior for get.
187187
188188
Look up the path in the internal dictionary.
189189
@@ -212,20 +212,44 @@ async def get_with_expression(
212212
| ListNodesFlags.EXCLUDE_STREAMING
213213
| ListNodesFlags.GET_ONLY,
214214
) -> list[AnnotatedValue]:
215-
"""Predefined behaviour for get_with_expression.
215+
"""Predefined behavior for get_with_expression.
216216
217217
Find all nodes associated with the path expression
218218
and call get for each of them.
219219
220220
Args:
221221
path_expression: Path expression to get.
222-
flags: Flags to control the behaviour of the get_with_expression method.
222+
flags: Flags to control the behavior of the get_with_expression method.
223223
224224
Returns:
225225
List of values, corresponding to nodes of the path expression.
226226
"""
227227
return [await self.get(p) for p in await self.list_nodes(path=path_expression)]
228228

229+
async def _update_subscriptions(self, value: AnnotatedValue) -> None:
230+
"""Update all subscriptions with the new value.
231+
232+
Args:
233+
value: New value.
234+
"""
235+
if self.memory[value.path].streaming_handles:
236+
# sending updated value to subscriptions
237+
result = await asyncio.gather(
238+
*[
239+
handle.send_value(value)
240+
for handle in self.memory[value.path].streaming_handles
241+
],
242+
)
243+
# Remove all disconnected subscriptions
244+
self.memory[value.path].streaming_handles = [
245+
handle
246+
for handle, success in zip(
247+
self.memory[value.path].streaming_handles,
248+
result,
249+
)
250+
if success
251+
]
252+
229253
@t.overload
230254
async def set(self, value: AnnotatedValue) -> AnnotatedValue: ...
231255

@@ -241,7 +265,7 @@ async def set(
241265
value: AnnotatedValue | Value,
242266
path: str = "",
243267
) -> AnnotatedValue:
244-
"""Predefined behaviour for set.
268+
"""Predefined behavior for set.
245269
246270
Updates the internal dictionary. A set command is considered
247271
as an update and will be distributed to all registered subscription handlers.
@@ -271,14 +295,7 @@ async def set(
271295
path=path,
272296
timestamp=self.get_timestamp(),
273297
)
274-
if self.memory[path].streaming_handles:
275-
# sending updated value to subscriptions
276-
await asyncio.gather(
277-
*[
278-
handle.send_value(response)
279-
for handle in self.memory[path].streaming_handles
280-
],
281-
)
298+
await self._update_subscriptions(value=response)
282299
return response
283300

284301
@t.overload
@@ -299,7 +316,7 @@ async def set_with_expression(
299316
value: AnnotatedValue | Value,
300317
path: LabOneNodePath | None = None,
301318
) -> list[AnnotatedValue]:
302-
"""Predefined behaviour for set_with_expression.
319+
"""Predefined behavior for set_with_expression.
303320
304321
Finds all nodes associated with the path expression
305322
and call set for each of them.
@@ -323,7 +340,7 @@ async def set_with_expression(
323340
return result
324341

325342
async def subscribe(self, subscription: Subscription) -> None:
326-
"""Predefined behaviour for subscribe.
343+
"""Predefined behavior for subscribe.
327344
328345
Stores the subscription. Whenever an update event happens
329346
they are distributed to all registered handles,

src/labone/mock/session.py

Lines changed: 22 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from abc import ABC, abstractmethod
1515
from typing import TYPE_CHECKING
1616

17+
import zhinst.comms
1718
from zhinst.comms.server import CapnpResult, CapnpServer, capnp_method
1819

1920
from labone.core import ListNodesFlags, ListNodesInfoFlags, hpk_schema
@@ -25,9 +26,6 @@
2526
value_from_python_types,
2627
)
2728

28-
if TYPE_CHECKING:
29-
import zhinst.comms
30-
3129
HPK_SCHEMA_ID = 0xA621130A90860008
3230
SESSION_SCHEMA_ID = 0xB9D445582DA4A55C
3331
SERVER_ERROR = "SERVER_ERROR"
@@ -60,26 +58,33 @@ def __init__(
6058
self._streaming_handle = streaming_handle
6159
self.subscriber_id = subscriber_id
6260

63-
async def send_value(self, value: AnnotatedValue) -> None:
61+
async def send_value(self, value: AnnotatedValue) -> bool:
6462
"""Send value to the subscriber.
6563
6664
Args:
6765
value: Value to send.
66+
67+
Returns:
68+
Flag indicating if the subscription is active
6869
"""
69-
await self._streaming_handle.sendValues(
70-
values=[
71-
{
72-
"value": value_from_python_types(
73-
value.value,
74-
capability_version=Session.CAPABILITY_VERSION,
75-
),
76-
"metadata": {
77-
"path": value.path,
78-
"timestamp": value.timestamp,
70+
try:
71+
await self._streaming_handle.sendValues(
72+
values=[
73+
{
74+
"value": value_from_python_types(
75+
value.value,
76+
capability_version=Session.CAPABILITY_VERSION,
77+
),
78+
"metadata": {
79+
"path": value.path,
80+
"timestamp": value.timestamp,
81+
},
7982
},
80-
},
81-
],
82-
)
83+
],
84+
)
85+
except zhinst.comms.errors.DisconnectError:
86+
return False
87+
return True
8388

8489
@property
8590
def path(self) -> LabOneNodePath:

tests/mock/module_test.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,16 @@ async def test_subscription():
4141
assert queue.empty()
4242

4343

44+
@pytest.mark.asyncio
45+
async def test_unsubscribe():
46+
session = await AutomaticLabOneServer({"/a/b": {}}).start_pipe()
47+
48+
queue = await session.subscribe("/a/b")
49+
queue.disconnect()
50+
await session.set(path="/a/b", value=7)
51+
assert queue.empty()
52+
53+
4454
@pytest.mark.asyncio
4555
async def test_subscription_multiple_changes():
4656
session = await AutomaticLabOneServer({"/a/b": {}}).start_pipe()

0 commit comments

Comments
 (0)