serverish 2.0.5__tar.gz → 2.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {serverish-2.0.5 → serverish-2.2.0}/PKG-INFO +2 -2
  2. {serverish-2.0.5 → serverish-2.2.0}/pyproject.toml +5 -5
  3. serverish-2.2.0/serverish/base/exceptions.py +87 -0
  4. {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/connection_jets.py +28 -0
  5. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/__init__.py +4 -0
  6. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/messenger.py +52 -0
  7. serverish-2.2.0/serverish/messenger/msg_kv.py +111 -0
  8. serverish-2.2.0/serverish/messenger/msg_kv_read.py +78 -0
  9. serverish-2.2.0/serverish/messenger/msg_kv_store.py +141 -0
  10. serverish-2.2.0/serverish/messenger/msg_kv_sub.py +111 -0
  11. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_publisher.py +45 -2
  12. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_reader.py +83 -63
  13. serverish-2.0.5/serverish/base/exceptions.py +0 -41
  14. {serverish-2.0.5 → serverish-2.2.0}/LICENSE +0 -0
  15. {serverish-2.0.5 → serverish-2.2.0}/README.md +0 -0
  16. {serverish-2.0.5 → serverish-2.2.0}/serverish/__init__.py +0 -0
  17. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/__init__.py +0 -0
  18. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/asyncio_util_functions.py +0 -0
  19. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/collector.py +0 -0
  20. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/datetime.py +0 -0
  21. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/fifoset.py +0 -0
  22. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/hasstatuses.py +0 -0
  23. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/idmanger.py +0 -0
  24. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/iterators.py +0 -0
  25. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/manageable.py +0 -0
  26. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/singleton.py +0 -0
  27. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/status.py +0 -0
  28. {serverish-2.0.5 → serverish-2.2.0}/serverish/base/task_manager.py +0 -0
  29. {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/__init__.py +0 -0
  30. {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/connection.py +0 -0
  31. {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/connection_nats.py +0 -0
  32. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/live_document.py +0 -0
  33. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/logging.py +0 -0
  34. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_callback_sub.py +0 -0
  35. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_cmd_pub.py +0 -0
  36. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_cmd_sub.py +0 -0
  37. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_core_pub.py +0 -0
  38. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_core_read.py +0 -0
  39. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_core_sub.py +0 -0
  40. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_document_read.py +0 -0
  41. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_journal_pub.py +0 -0
  42. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_journal_read.py +0 -0
  43. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_progress_pub.py +0 -0
  44. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_progress_read.py +0 -0
  45. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_rpc_req.py +0 -0
  46. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_rpc_resp.py +0 -0
  47. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_single_pub.py +0 -0
  48. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_single_read.py +0 -0
  49. {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msgvalidator.py +0 -0
  50. {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/__init__.py +0 -0
  51. {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/bridge.py +0 -0
  52. {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/factory.py +0 -0
  53. {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/monitored.py +0 -0
  54. {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/monitored_nats.py +0 -0
  55. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/config.schema.json +0 -0
  56. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/default.schema.json +0 -0
  57. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/include/timestamp.schema.json +0 -0
  58. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/journal.schema.json +0 -0
  59. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/meta.schema.json +0 -0
  60. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/program_current_state.schema.json +0 -0
  61. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/program_state.schema.json +0 -0
  62. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/progress.schema.json +0 -0
  63. {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/telemetry.schema.json +0 -0
  64. {serverish-2.0.5 → serverish-2.2.0}/serverish/services/TODO.txt +0 -0
  65. {serverish-2.0.5 → serverish-2.2.0}/serverish/slog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverish
3
- Version: 2.0.5
3
+ Version: 2.2.0
4
4
  Summary: helpers for server alike projects
5
5
  License-File: LICENSE
6
6
  Author: Mikołaj Kałuszyński
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Provides-Extra: dns
15
15
  Provides-Extra: messenger
16
- Requires-Dist: aiodns (>=3.2.0,<4.0.0) ; extra == "dns"
16
+ Requires-Dist: aiodns (>=3.2,<5) ; extra == "dns"
17
17
  Requires-Dist: jsonschema (>=4.20.0,<5.0.0)
18
18
  Requires-Dist: nats-py (>=2.7.2,<3.0.0) ; extra == "messenger"
19
19
  Requires-Dist: param (>=2.1.0,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "serverish"
3
- version = "2.0.5"
3
+ version = "2.2.0"
4
4
  description = "helpers for server alike projects"
5
5
  authors = ["Mikołaj Kałuszyński", "MMME team"]
6
6
  readme = "README.md"
@@ -9,7 +9,7 @@ include = ["serverish/schema/*"]
9
9
 
10
10
  [tool.poetry.dependencies]
11
11
  python = "^3.10"
12
- aiodns = {version="^3.2.0", optional=true}
12
+ aiodns = {version=">=3.2,<5", optional=true}
13
13
  param = "^2.1.0"
14
14
  nats-py = { version = "^2.7.2", optional= true}
15
15
  jsonschema = "^4.20.0"
@@ -20,12 +20,12 @@ dns = ["aiodns"]
20
20
 
21
21
 
22
22
  [tool.poetry.group.dev.dependencies]
23
- pytest = ">=8.2,<9"
24
- pytest-asyncio = ">=0.24,<1"
23
+ pytest = ">=9.1,<10"
24
+ pytest-asyncio = "^1.4"
25
25
  nats-py = "^2.6.0"
26
26
  jupyter = "*"
27
27
  rich = "^13.5.2"
28
- aiodns = "^3.1.1"
28
+ aiodns = ">=3.1.1,<5"
29
29
  testcontainers = {extras = ["nats"], version = "^4.14.2"}
30
30
  pytest-timeout = "^2.4.0"
31
31
 
@@ -0,0 +1,87 @@
1
+ class ServerishError(Exception):
2
+ """Common base for all serverish exceptions
3
+
4
+ Catch this to handle any error originating from the serverish library,
5
+ regardless of the subsystem (messenger, connection, KV). Concrete
6
+ exceptions additionally inherit fitting standard bases (ValueError,
7
+ TimeoutError, KeyError...), so idiomatic generic handlers keep working.
8
+ """
9
+ pass
10
+
11
+
12
+ class MessengerCannotConnect(ServerishError):
13
+ pass
14
+
15
+ class MessengerNotConnected(ServerishError):
16
+ pass
17
+
18
+ class MessengerReaderStopped(ServerishError):
19
+ pass
20
+
21
+ class MessengerReaderConfigError(ServerishError, ValueError):
22
+ """Raised when a MsgReader is misconfigured (e.g. missing required start marker).
23
+
24
+ This is a fatal error — the same configuration will never succeed against
25
+ the NATS server. The reader stops and this exception propagates out of
26
+ ``async for`` loops so callers fail loudly instead of retrying forever.
27
+ """
28
+ pass
29
+
30
+ class MessengerReaderAlreadyOpen(ServerishError, RuntimeError):
31
+ pass
32
+
33
+ class MessengerRequestNoResponse(ServerishError):
34
+ pass
35
+
36
+ class MessengerRequestNoResponders(MessengerRequestNoResponse):
37
+ pass
38
+
39
+ class MessengerRequestJetStreamSubject(MessengerRequestNoResponse):
40
+ def __init__(self, subject:str) -> None:
41
+ super().__init__(f'Subject {subject} probably declared in JetStream stream. '
42
+ f'Use pure NATS core subjects for RPC')
43
+
44
+
45
+ class MessengerRequestCanceled(MessengerRequestNoResponse):
46
+ pass
47
+
48
+ class MessengerRequestTimeout(MessengerRequestNoResponse, TimeoutError):
49
+ pass
50
+
51
+ class MessengerRequestNoResultYet(MessengerRequestNoResponse):
52
+ pass
53
+
54
+
55
+ class MessengerPublishAckTimeout(ServerishError, TimeoutError):
56
+ """Raised when JetStream did not confirm a publish within the ack timeout.
57
+
58
+ The message MAY have been delivered and stored — only the acknowledgement
59
+ did not arrive (or was not processed) in time. A starved client event loop
60
+ is the most common cause, not an actual delivery failure. Publishes carry
61
+ a ``Nats-Msg-Id`` header, so JetStream deduplicates retries of the same
62
+ message within the stream's duplicate window.
63
+ """
64
+ pass
65
+
66
+
67
+ class MessengerKvError(ServerishError):
68
+ pass
69
+
70
+
71
+ class MessengerKvBucketNotFound(MessengerKvError):
72
+ """Raised when a KV bucket does not exist and the driver was not allowed to create it."""
73
+ pass
74
+
75
+
76
+ class MessengerKvKeyNotFound(MessengerKvError, KeyError):
77
+ pass
78
+
79
+
80
+ class MessengerKvMalformed(MessengerKvError):
81
+ """Raised when a KV entry does not carry a serverish envelope.
82
+
83
+ Serverish stores full ``{"data": ..., "meta": ...}`` envelopes in KV buckets.
84
+ An entry written by a non-serverish client fails loudly instead of being
85
+ silently returned as empty.
86
+ """
87
+ pass
@@ -5,6 +5,7 @@ from typing import Mapping, Any, Iterable
5
5
  import re
6
6
 
7
7
  import nats
8
+ import nats.js.errors
8
9
  from nats.js import JetStreamContext
9
10
  import param
10
11
 
@@ -147,6 +148,33 @@ class ConnectionJetStream(ConnectionNATS):
147
148
  cfg.subjects.append(subject)
148
149
  await js.update_stream(config=cfg)
149
150
 
151
+ async def ensure_kv_bucket(self, bucket: str, create_if_needed: bool = False, **config):
152
+ """Binds to a JetStream KV bucket, optionally creating it.
153
+
154
+ Like streams (see `ensure_subject_in_stream`), buckets are not created on the fly
155
+ by default, because usually one has to control the bucket parameters.
156
+
157
+ Args:
158
+ bucket (str): KV bucket name
159
+ create_if_needed (bool): create the bucket if it does not exist
160
+ config: `nats.js.api.KeyValueConfig` parameters (e.g. history, ttl, max_bytes)
161
+ used only when the bucket is being created
162
+
163
+ Returns:
164
+ nats.js.kv.KeyValue: bound KV bucket handle
165
+
166
+ Raises:
167
+ nats.js.errors.BucketNotFoundError: bucket does not exist and create_if_needed is False
168
+ """
169
+ js: JetStreamContext = self.js
170
+ try:
171
+ return await js.key_value(bucket)
172
+ except nats.js.errors.BucketNotFoundError:
173
+ if not create_if_needed:
174
+ raise
175
+ logger.info(f"Creating KV bucket {bucket}")
176
+ return await js.create_key_value(bucket=bucket, **config)
177
+
150
178
 
151
179
  async def diagnose_stream_config(self) -> StatusReport:
152
180
  """Diagnoses stream configuration
@@ -19,3 +19,7 @@ from .msg_core_read import MsgCoreReader, get_corereader
19
19
  from .msg_core_sub import MsgCoreSub, get_coresubscriber
20
20
  from .msg_cmd_pub import MsgCommandPublisher, get_commandpublisher
21
21
  from .msg_cmd_sub import MsgCommandSubscriber, get_commandsubscriber
22
+ from .msg_kv import MsgKvDriver
23
+ from .msg_kv_store import MsgKvStore, get_kvstore, kv_get, kv_put
24
+ from .msg_kv_read import MsgKvReader, get_kvreader
25
+ from .msg_kv_sub import MsgKvSubscriber, get_kvsubscriber
@@ -627,6 +627,58 @@ class Messenger(Singleton):
627
627
  from serverish.messenger.msg_cmd_sub import MsgCommandSubscriber
628
628
  return MsgCommandSubscriber(subject=subject, parent=Messenger())
629
629
 
630
+ @staticmethod
631
+ def get_kvstore(bucket: str, **kwargs) -> 'MsgKvStore':
632
+ """Returns a key-value store for a given JetStream KV bucket
633
+
634
+ Values are full serverish envelopes, validated like published messages.
635
+
636
+ Args:
637
+ bucket (str): KV bucket name
638
+ kwargs: additional driver arguments (e.g. create_bucket, bucket_config)
639
+
640
+ Returns:
641
+ MsgKvStore: a key-value store for the given bucket
642
+ """
643
+ from serverish.messenger.msg_kv_store import MsgKvStore
644
+ return MsgKvStore(bucket=bucket, parent=Messenger(), **kwargs)
645
+
646
+ @staticmethod
647
+ def get_kvreader(bucket: str, key: str = '>', **kwargs) -> 'MsgKvReader':
648
+ """Returns a KV reader (async iterator over key changes) for a given bucket
649
+
650
+ Args:
651
+ bucket (str): KV bucket name
652
+ key (str): key or wildcard pattern to watch
653
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
654
+
655
+ Returns:
656
+ MsgKvReader: a KV reader for the given bucket
657
+
658
+ Usage:
659
+ reader = Messenger.get_kvreader('bucket', key='telescope.>')
660
+ await reader.open()
661
+ async for data, meta in reader:
662
+ print(meta['kv']['key'], data)
663
+ """
664
+ from serverish.messenger.msg_kv_read import MsgKvReader
665
+ return MsgKvReader(bucket=bucket, key=key, parent=Messenger(), **kwargs)
666
+
667
+ @staticmethod
668
+ def get_kvsubscriber(bucket: str, key: str = '>', **kwargs) -> 'MsgKvSubscriber':
669
+ """Returns a callback-based KV subscriber for a given bucket
670
+
671
+ Args:
672
+ bucket (str): KV bucket name
673
+ key (str): key or wildcard pattern to watch
674
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
675
+
676
+ Returns:
677
+ MsgKvSubscriber: a callback-based KV subscriber for the given bucket
678
+ """
679
+ from serverish.messenger.msg_kv_sub import MsgKvSubscriber
680
+ return MsgKvSubscriber(bucket=bucket, key=key, parent=Messenger(), **kwargs)
681
+
630
682
 
631
683
  class MsgDriver(Manageable):
632
684
  subject: str = param.String(default=None, allow_None=True, doc="User subject to publish to, prefix may be added")
@@ -0,0 +1,111 @@
1
+ """Key-value bucket support for Messenger.
2
+
3
+ Provides `MsgKvDriver`, the common base for all KV drivers (`MsgKvStore`,
4
+ `MsgKvReader`, `MsgKvSubscriber`). NATS JetStream KV buckets are the backend;
5
+ values are always full serverish envelopes ``{"data": ..., "meta": ...}``,
6
+ so schema validation and message metadata work exactly as for stream messages.
7
+
8
+ Delete and purge markers are the only entries without an envelope - the KV
9
+ protocol stores them as empty tombstones. Drivers surface them as
10
+ ``(None, meta)`` with ``meta['kv']['operation']`` set to ``'DEL'`` or ``'PURGE'``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+
16
+ import jsonschema
17
+ import nats.js.errors
18
+ import param
19
+ from nats.js.kv import KeyValue
20
+
21
+ from serverish.base import MessengerKvBucketNotFound, MessengerKvMalformed, dt_ensure_array
22
+ from serverish.messenger.messenger import MsgDriver
23
+
24
+ log = logging.getLogger(__name__.rsplit('.')[-1])
25
+
26
+
27
+ class MsgKvDriver(MsgDriver):
28
+ """Base for KV bucket operators
29
+
30
+ KV drivers operate on a bucket (and keys within it) instead of a subject.
31
+ The inherited `subject` is set to the underlying ``$KV.<bucket>`` prefix
32
+ for diagnostics only.
33
+
34
+ Args:
35
+ bucket (str): KV bucket name
36
+ create_bucket (bool): create the bucket on open() if it does not exist
37
+ bucket_config (dict): `nats.js.api.KeyValueConfig` parameters (e.g. history, ttl)
38
+ used only when the bucket is being created
39
+ """
40
+ bucket = param.String(default=None, allow_None=True, doc="KV bucket name")
41
+ create_bucket = param.Boolean(default=False, doc="Create the bucket on open() if it does not exist")
42
+ bucket_config = param.Dict(default={}, doc="KeyValueConfig parameters used when creating the bucket")
43
+
44
+ def __init__(self, **kwargs) -> None:
45
+ self.kv: KeyValue | None = None
46
+ super().__init__(**kwargs)
47
+ if self.subject is None and self.bucket:
48
+ self.subject = f'$KV.{self.bucket}' # diagnostic only, KV entries are addressed by bucket/key
49
+
50
+ async def open(self) -> None:
51
+ try:
52
+ self.kv = await self.connection.ensure_kv_bucket(self.bucket,
53
+ create_if_needed=self.create_bucket,
54
+ **self.bucket_config)
55
+ except nats.js.errors.BucketNotFoundError as e:
56
+ raise MessengerKvBucketNotFound(
57
+ f"KV bucket '{self.bucket}' does not exist, "
58
+ f"create it externally or open the driver with create_bucket=True") from e
59
+ await super().open()
60
+
61
+ async def close(self) -> None:
62
+ self.kv = None
63
+ await super().close()
64
+
65
+ def encode_envelope(self, data: dict | None, meta: dict | None) -> tuple[dict, bytes]:
66
+ """Creates and validates a serverish envelope, returns (message, encoded bytes)"""
67
+ msg = self.messenger.create_msg(data, meta)
68
+ try:
69
+ self.messenger.msg_validate(msg)
70
+ except jsonschema.ValidationError as e:
71
+ log.error(f"Message {msg['meta']['id']} validation error: {e}")
72
+ raise e
73
+ return msg, self.messenger.encode(msg)
74
+
75
+ def decode_entry(self, entry: KeyValue.Entry) -> tuple[dict | None, dict]:
76
+ """Decodes a KV entry into (data, meta)
77
+
78
+ Delete/purge markers carry no envelope: data is None and meta contains
79
+ only the 'kv' section.
80
+
81
+ Raises:
82
+ MessengerKvMalformed: entry value is not a serverish envelope
83
+ """
84
+ operation = entry.operation or 'PUT'
85
+ kv_meta = {
86
+ 'bucket': entry.bucket,
87
+ 'key': entry.key,
88
+ 'revision': entry.revision,
89
+ 'operation': operation,
90
+ }
91
+ if entry.created is not None:
92
+ kv_meta['created'] = dt_ensure_array(entry.created)
93
+ if operation != 'PUT':
94
+ return None, {'kv': kv_meta}
95
+ try:
96
+ msg = self.messenger.decode(entry.value)
97
+ except (ValueError, TypeError) as e:
98
+ raise MessengerKvMalformed(
99
+ f"Entry '{entry.key}'@{entry.revision} in KV bucket '{entry.bucket}' is not "
100
+ f"a serverish envelope, was it written by a non-serverish client?") from e
101
+ if not isinstance(msg, dict) or 'meta' not in msg:
102
+ raise MessengerKvMalformed(
103
+ f"Entry '{entry.key}'@{entry.revision} in KV bucket '{entry.bucket}' carries "
104
+ f"no meta, was it written by a non-serverish client?")
105
+ data = msg.get('data', {})
106
+ meta = msg['meta']
107
+ meta['kv'] = kv_meta
108
+ return data, meta
109
+
110
+ def __str__(self):
111
+ return f'{self.name} [{self.bucket}]'
@@ -0,0 +1,78 @@
1
+ from __future__ import annotations
2
+
3
+ import param
4
+ from nats.js.kv import KeyValue
5
+
6
+ from serverish.base import MessengerReaderStopped
7
+ from serverish.messenger.messenger import Messenger
8
+ from serverish.messenger.msg_kv import MsgKvDriver, log
9
+
10
+
11
+ class MsgKvReader(MsgKvDriver):
12
+ """An async-iterator over changes of KV keys
13
+
14
+ Iteration yields (data, meta) pairs like `MsgReader`; the key, revision and
15
+ operation are available in `meta['kv']`. On open, current values of matching
16
+ keys are delivered first, then live updates as they happen.
17
+
18
+ Delete/purge markers are yielded as (None, meta) with
19
+ meta['kv']['operation'] set to 'DEL'/'PURGE' (unless ignore_deletes is set).
20
+
21
+ Usage:
22
+ reader = get_kvreader('bucket', key='telescope.>')
23
+ await reader.open()
24
+ async for data, meta in reader:
25
+ print(meta['kv']['key'], data)
26
+ """
27
+ key = param.String(default='>', doc="Key or wildcard pattern to watch")
28
+ include_history = param.Boolean(default=False, doc="Deliver historical revisions of the keys on open")
29
+ ignore_deletes = param.Boolean(default=False, doc="Skip delete/purge markers")
30
+
31
+ def __init__(self, **kwargs) -> None:
32
+ self._watcher: KeyValue.KeyWatcher | None = None
33
+ super().__init__(**kwargs)
34
+
35
+ async def open(self) -> None:
36
+ await super().open()
37
+ self._watcher = await self.kv.watch(self.key,
38
+ include_history=self.include_history,
39
+ ignore_deletes=self.ignore_deletes)
40
+
41
+ async def close(self) -> None:
42
+ if self._watcher is not None:
43
+ try:
44
+ await self._watcher.stop()
45
+ except Exception as e:
46
+ log.debug(f"Error stopping KV watcher for {self}: {e}")
47
+ self._watcher = None
48
+ await super().close()
49
+
50
+ def __aiter__(self):
51
+ return self
52
+
53
+ async def __anext__(self) -> tuple[dict | None, dict]:
54
+ if self._watcher is None:
55
+ raise MessengerReaderStopped(f"KV reader {self} is not open")
56
+ while True:
57
+ entry = await self._watcher.__anext__() # raises StopAsyncIteration when watcher is stopped
58
+ if entry is None:
59
+ # nats-py sends a None marker once the initial replay is done, it is not a value
60
+ continue
61
+ return self.decode_entry(entry)
62
+
63
+ def __str__(self):
64
+ return f'{self.name} [{self.bucket}/{self.key}]'
65
+
66
+
67
+ def get_kvreader(bucket: str, key: str = '>', **kwargs) -> MsgKvReader:
68
+ """Returns a KV reader for a given bucket and key pattern
69
+
70
+ Args:
71
+ bucket (str): KV bucket name
72
+ key (str): key or wildcard pattern to watch
73
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
74
+
75
+ Returns:
76
+ MsgKvReader: a KV reader for the given bucket
77
+ """
78
+ return Messenger.get_kvreader(bucket, key=key, **kwargs)
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import nats.js.errors
4
+ from nats.js.kv import KeyValue
5
+
6
+ from serverish.base import MessengerKvKeyNotFound
7
+ from serverish.messenger.messenger import Messenger, MsgDriver
8
+ from serverish.messenger.msg_kv import MsgKvDriver, log
9
+
10
+
11
+ class MsgKvStore(MsgKvDriver):
12
+ """A key-value store over a NATS JetStream KV bucket
13
+
14
+ Values are full serverish envelopes, so `get` returns the usual
15
+ (data, meta) pair and `put` validates the message against schemas.
16
+
17
+ All operations are decorated with `ensure_open`, so the store can be used
18
+ one-shot without explicit open/close, like other messenger drivers.
19
+ """
20
+
21
+ @MsgDriver.ensure_open
22
+ async def put(self, key: str, data: dict | None = None, meta: dict | None = None) -> dict:
23
+ """Stores a value for the key, returns the published message
24
+
25
+ Args:
26
+ key (str): key to store the value under
27
+ data (dict): message data
28
+ meta (dict): message metadata
29
+
30
+ Returns:
31
+ dict: published message, with meta['kv'] carrying bucket/key/revision
32
+ """
33
+ msg, bdata = self.encode_envelope(data, meta)
34
+ revision = await self.kv.put(key, bdata)
35
+ msg['meta']['kv'] = {'bucket': self.bucket, 'key': key, 'revision': revision, 'operation': 'PUT'}
36
+ self.messenger.log_msg_trace(msg.get('data', {}), msg['meta'], f"KV PUT {self.bucket}/{key}")
37
+ return msg
38
+
39
+ @MsgDriver.ensure_open
40
+ async def get(self, key: str, revision: int | None = None) -> tuple[dict, dict]:
41
+ """Returns (data, meta) of the latest (or given revision) value for the key
42
+
43
+ Args:
44
+ key (str): key to read
45
+ revision (int): specific revision to read, latest if None
46
+
47
+ Raises:
48
+ MessengerKvKeyNotFound: key does not exist (or is deleted)
49
+ MessengerKvMalformed: entry is not a serverish envelope
50
+ """
51
+ try:
52
+ entry = await self.kv.get(key, revision=revision)
53
+ except nats.js.errors.KeyNotFoundError as e:
54
+ raise MessengerKvKeyNotFound(f"Key '{key}' not found in KV bucket '{self.bucket}'") from e
55
+ data, meta = self.decode_entry(entry)
56
+ self.messenger.log_msg_trace(data, meta, f"KV GET {self.bucket}/{key}")
57
+ return data, meta
58
+
59
+ @MsgDriver.ensure_open
60
+ async def delete(self, key: str) -> None:
61
+ """Places a delete marker for the key and removes previous revisions"""
62
+ await self.kv.delete(key)
63
+ log.debug(f"KV DEL {self.bucket}/{key}")
64
+
65
+ @MsgDriver.ensure_open
66
+ async def purge(self, key: str) -> None:
67
+ """Removes the key including all its revisions"""
68
+ await self.kv.purge(key)
69
+ log.debug(f"KV PURGE {self.bucket}/{key}")
70
+
71
+ @MsgDriver.ensure_open
72
+ async def keys(self) -> list[str]:
73
+ """Returns list of keys in the bucket, empty list for an empty bucket"""
74
+ try:
75
+ return await self.kv.keys()
76
+ except nats.js.errors.NoKeysError:
77
+ return []
78
+
79
+ @MsgDriver.ensure_open
80
+ async def history(self, key: str) -> list[tuple[dict | None, dict]]:
81
+ """Returns the revision history of the key, oldest first
82
+
83
+ Delete/purge markers are returned as (None, meta) entries.
84
+ Note: the bucket must be created with history > 1 to keep past revisions.
85
+
86
+ Raises:
87
+ MessengerKvKeyNotFound: key has no history in the bucket
88
+ """
89
+ try:
90
+ entries = await self.kv.history(key)
91
+ except nats.js.errors.NoKeysError as e:
92
+ raise MessengerKvKeyNotFound(f"Key '{key}' has no history in KV bucket '{self.bucket}'") from e
93
+ return [self.decode_entry(entry) for entry in entries]
94
+
95
+ @MsgDriver.ensure_open
96
+ async def status(self) -> KeyValue.BucketStatus:
97
+ """Returns the status of the underlying KV bucket"""
98
+ return await self.kv.status()
99
+
100
+
101
+ def get_kvstore(bucket: str, **kwargs) -> MsgKvStore:
102
+ """Returns a key-value store for a given KV bucket
103
+
104
+ Args:
105
+ bucket (str): KV bucket name
106
+ kwargs: additional driver arguments (e.g. create_bucket, bucket_config)
107
+
108
+ Returns:
109
+ MsgKvStore: a key-value store for the given bucket
110
+ """
111
+ return Messenger.get_kvstore(bucket, **kwargs)
112
+
113
+
114
+ async def kv_put(bucket: str, key: str, data: dict | None = None, meta: dict | None = None, **kwargs) -> dict:
115
+ """Stores a single value in a KV bucket (one-shot)
116
+
117
+ Args:
118
+ bucket (str): KV bucket name
119
+ key (str): key to store the value under
120
+ data (dict): message data
121
+ meta (dict): message metadata
122
+ kwargs: additional driver arguments (e.g. create_bucket)
123
+
124
+ Returns:
125
+ dict: published message
126
+ """
127
+ return await get_kvstore(bucket, **kwargs).put(key, data, meta)
128
+
129
+
130
+ async def kv_get(bucket: str, key: str, **kwargs) -> tuple[dict, dict]:
131
+ """Reads a single value from a KV bucket (one-shot)
132
+
133
+ Args:
134
+ bucket (str): KV bucket name
135
+ key (str): key to read
136
+ kwargs: additional driver arguments
137
+
138
+ Returns:
139
+ tuple[dict, dict]: (data, meta) of the stored message
140
+ """
141
+ return await get_kvstore(bucket, **kwargs).get(key)
@@ -0,0 +1,111 @@
1
+ from __future__ import annotations
2
+
3
+ import asyncio
4
+ from asyncio import CancelledError, Event
5
+ from typing import Callable
6
+
7
+ import param
8
+
9
+ from serverish.base import Task, create_task
10
+ from serverish.messenger.messenger import Messenger
11
+ from serverish.messenger.msg_kv_read import MsgKvReader
12
+ from serverish.messenger.msg_kv import log
13
+
14
+
15
+ class MsgKvSubscriber(MsgKvReader):
16
+ """A class for watching KV keys and calling a callback function on each change
17
+
18
+ This class works like `MsgKvReader`, but allows to specify a callback function
19
+ for each change instead of iterating.
20
+
21
+ The callback is called with (data, meta); data is None for delete/purge markers
22
+ (check meta['kv']['operation']). Note that current values of matching keys are
23
+ delivered on subscribe, before live updates.
24
+
25
+ Usage:
26
+ def callback(data, meta):
27
+ print(meta['kv']['key'], data)
28
+
29
+ sub = get_kvsubscriber('bucket', key='telescope.>')
30
+ await sub.open()
31
+ await sub.subscribe(callback)
32
+ """
33
+ callback = param.Callable(default=None, doc="Callback function to call on each change")
34
+ task = param.ClassSelector(default=None, class_=Task, doc="Task for watching changes")
35
+
36
+ def __init__(self, **kwargs) -> None:
37
+ self._stop_event = Event()
38
+ super().__init__(**kwargs)
39
+
40
+ async def close(self) -> None:
41
+ await self.stop()
42
+ if self.task is not None:
43
+ self.task.cancel()
44
+ return await super().close()
45
+
46
+ async def stop(self) -> None:
47
+ """Stops watching changes"""
48
+ self._stop_event.set()
49
+
50
+ async def subscribe(self, callback: Callable[[dict | None, dict], bool] |
51
+ Callable[[dict | None, dict], asyncio.Future]) -> Task:
52
+ """Sets a callback function for each change of watched keys
53
+
54
+ Args:
55
+ callback: a callback function to call on each change, may be asynchronous
56
+ callback is called with two arguments: data dict (None for delete/purge
57
+ markers) and metadata dict, and may return False to stop watching.
58
+ Any other return value (including None — i.e. callbacks with no explicit
59
+ return — and True) keeps the subscription running.
60
+ """
61
+ self.callback = callback
62
+ if asyncio.iscoroutinefunction(callback):
63
+ self.task = await create_task(self._task_body(acb=callback), f'KVASUB.{self.bucket}.{self.key}')
64
+ else:
65
+ self.task = await create_task(self._task_body(scb=callback), f'KVSSUB.{self.bucket}.{self.key}')
66
+ return self.task
67
+
68
+ async def _task_body(self,
69
+ scb: Callable[[dict | None, dict], bool] | None = None,
70
+ acb: Callable[[dict | None, dict], asyncio.Future] | None = None
71
+ ) -> None:
72
+
73
+ assert scb is not None or acb is not None
74
+ assert not (scb is not None and acb is not None)
75
+ cb = scb or acb
76
+ cont: object = True
77
+ log.debug(f"Entering KV watch iteration {self}")
78
+ async for data, meta in self:
79
+ try:
80
+ if scb is not None:
81
+ log.debug(f"Calling sync callback {cb} for KV change {meta}{str(data):20}")
82
+ cont = scb(data, meta)
83
+ else:
84
+ log.debug(f"Calling async callback {cb} for KV change {meta}{str(data):20}")
85
+ cont = await acb(data, meta)
86
+ except CancelledError:
87
+ log.debug(f'Cancelled {self}')
88
+ break
89
+ except Exception as e:
90
+ log.exception(f'Error in callback {cb} for KV change {meta}{str(data):20}: {e}')
91
+ # Stop only on explicit False — None (the implicit "no return" value
92
+ # of a Python function) and any truthy value keep the subscription
93
+ # alive. Treating None as "stop" would silently kill any callback
94
+ # that doesn't bother returning anything, which is the common case.
95
+ if cont is False or self._stop_event.is_set():
96
+ break
97
+ log.debug(f"Exiting KV watch iteration {self}")
98
+
99
+
100
+ def get_kvsubscriber(bucket: str, key: str = '>', **kwargs) -> MsgKvSubscriber:
101
+ """Returns a callback-based KV subscriber for a given bucket and key pattern
102
+
103
+ Args:
104
+ bucket (str): KV bucket name
105
+ key (str): key or wildcard pattern to watch
106
+ kwargs: additional driver arguments (e.g. include_history, ignore_deletes)
107
+
108
+ Returns:
109
+ MsgKvSubscriber: a callback-based KV subscriber for the given bucket
110
+ """
111
+ return Messenger.get_kvsubscriber(bucket, key=key, **kwargs)
@@ -7,7 +7,7 @@ import nats.errors
7
7
  import nats.js
8
8
  import param
9
9
 
10
- from serverish.base import MessengerNotConnected
10
+ from serverish.base import MessengerNotConnected, MessengerPublishAckTimeout
11
11
  from serverish.messenger import Messenger
12
12
  from serverish.messenger.messenger import MsgDriver, log
13
13
 
@@ -18,8 +18,19 @@ class MsgPublisher(MsgDriver):
18
18
  Use this class if you want to publish data to a messenger subject.
19
19
  Check for specialist publishers for common use cases.
20
20
 
21
+ Every publish carries a ``Nats-Msg-Id`` header equal to the message
22
+ ``meta.id``, so JetStream deduplicates repeated deliveries of the same
23
+ message within the stream's duplicate window. This makes retrying after
24
+ an ack timeout safe: if the original publish did reach the stream (a
25
+ common case when the client event loop is starved and the ack is simply
26
+ processed too late), the retry is discarded by the server.
27
+
21
28
  Parameters:
22
29
  raise_on_publish_error (bool): Raise on publish error, default `True` re-raises underlying exceptions
30
+ ack_timeout_retries (int): How many times to retry a publish for which no JetStream
31
+ ack arrived in time (default 2). Retries are deduplicated server-side via
32
+ ``Nats-Msg-Id``. When retries are exhausted, `MessengerPublishAckTimeout` is
33
+ raised (subject to `raise_on_publish_error`).
23
34
 
24
35
  Health monitoring:
25
36
  The publisher tracks publish statistics accessible via `health_status` property:
@@ -30,6 +41,9 @@ class MsgPublisher(MsgDriver):
30
41
  """
31
42
 
32
43
  raise_on_publish_error = param.Boolean(default=True, doc="Raise on publish error")
44
+ ack_timeout_retries = param.Integer(default=2, bounds=(0, None),
45
+ doc="Retries when JetStream ack does not arrive in time; "
46
+ "safe thanks to Nats-Msg-Id deduplication")
33
47
 
34
48
  def __init__(self, **kwargs) -> None:
35
49
  # Health monitoring fields
@@ -60,8 +74,10 @@ class MsgPublisher(MsgDriver):
60
74
  log.error(f"Message {msg['meta']['id']} validation error: {e}")
61
75
  raise e
62
76
  self.messenger.log_msg_trace(msg['data'], msg['meta'], f"PUB to {self.subject}")
77
+ headers = dict(kwargs.pop('headers', None) or {})
78
+ headers.setdefault('Nats-Msg-Id', msg['meta']['id']) # server-side dedup, makes ack-timeout retries safe
63
79
  try:
64
- await self.connection.js.publish(self.subject, bdata, **kwargs)
80
+ await self._publish_with_ack_retries(bdata, headers, msg['meta']['id'], **kwargs)
65
81
  # Track successful publish
66
82
  self._publish_count += 1
67
83
  self._last_publish_time = time.monotonic()
@@ -90,6 +106,33 @@ class MsgPublisher(MsgDriver):
90
106
  msg['meta']['status'] = str(e)
91
107
  return msg
92
108
 
109
+ async def _publish_with_ack_retries(self, bdata: bytes, headers: dict, msg_id: str, **kwargs) -> None:
110
+ """Publishes to JetStream, retrying when the ack does not arrive in time
111
+
112
+ A missing ack does not mean the message was not delivered - it may
113
+ well be stored while the ack was processed too late (e.g. starved
114
+ client event loop). Retries are deduplicated server-side via the
115
+ Nats-Msg-Id header, so this never produces duplicates.
116
+
117
+ Raises:
118
+ MessengerPublishAckTimeout: no ack after all retries
119
+ """
120
+ for attempt in range(self.ack_timeout_retries + 1):
121
+ try:
122
+ await self.connection.js.publish(self.subject, bdata, headers=headers, **kwargs)
123
+ return
124
+ except nats.errors.TimeoutError as e:
125
+ if attempt < self.ack_timeout_retries:
126
+ log.warning(f"No JetStream ack for message {msg_id} on '{self.subject}' "
127
+ f"(attempt {attempt + 1}/{self.ack_timeout_retries + 1}), retrying - "
128
+ f"deduplicated by Nats-Msg-Id")
129
+ continue
130
+ raise MessengerPublishAckTimeout(
131
+ f"No JetStream ack for message {msg_id} on '{self.subject}' "
132
+ f"after {self.ack_timeout_retries + 1} attempts. The message MAY have been "
133
+ f"delivered - a missing ack often means a starved client event loop, "
134
+ f"not a delivery failure") from e
135
+
93
136
  @property
94
137
  def health_status(self) -> dict:
95
138
  """Returns current health status of the publisher for monitoring
@@ -86,6 +86,7 @@ class MsgReader(MsgDriver):
86
86
  }
87
87
  if consumer_cfg is not None:
88
88
  consumer_cfg_defaults.update(consumer_cfg)
89
+ # pull request size == read-ahead buffer bound; becomes BatchPolicy (docs/design/POLICIES.md)
89
90
  self.batch = 100
90
91
  self.messages = deque()
91
92
  self.pull_subscription: JetStreamContext.PullSubscription | None = None
@@ -435,12 +436,16 @@ class MsgReader(MsgDriver):
435
436
  Uses no_wait=True protocol:
436
437
  - Server sends available messages immediately
437
438
  - Server sends 404 status if no messages exist
438
- - We wait for server response (network latency) but not for new messages to arrive
439
+
440
+ End-of-data is detected deterministically from the `num_pending`
441
+ counter the server stamps on every delivered message - the call
442
+ returns as soon as the server confirms there is nothing more (or the
443
+ batch is full), with no fixed waiting anywhere on the happy path.
439
444
 
440
445
  Args:
441
446
  batch: Maximum number of messages to fetch
442
- timeout: Network latency budget (default 2s for local/fast networks)
443
- NOT a "wait for messages" timeout - server won't wait
447
+ timeout: Network-failure backstop (default 2s). Never consumed on
448
+ the happy path - NOT a "wait for messages" timeout
444
449
 
445
450
  Returns:
446
451
  List of messages that were immediately available on server
@@ -477,78 +482,93 @@ class MsgReader(MsgDriver):
477
482
  pull_subscription._deliver,
478
483
  )
479
484
 
480
- # Wait for server response (messages and/or 404 status)
481
- # The server will respond quickly because no_wait=True
482
- # We just need to account for network latency
485
+ # Wait for server response (messages and/or closing status).
486
+ #
487
+ # Deterministic end-of-data: every JetStream delivery is stamped with
488
+ # `num_pending` — the server-side count of messages still remaining
489
+ # for this consumer *after* that message (correct also for filtered
490
+ # and last_per_subject consumers). The loop therefore ends without
491
+ # any arbitrary grace period:
492
+ # - a message arrives with num_pending == 0 → nothing left, return,
493
+ # - batch filled while num_pending > 0 → return, the caller pulls again,
494
+ # - 404 (empty subject) / 408 (partial-batch closure) → server-confirmed end.
495
+ # `timeout` is a network-failure backstop only; no happy path consumes
496
+ # it, so the call is as fast as the server and the network allow.
483
497
  start_time = time.monotonic()
484
- got_404_status = False
485
- got_any_message = False
486
-
487
- while not got_404_status:
488
- deadline = timeout - (time.monotonic() - start_time)
489
- if deadline <= 0:
490
- # Timeout - if we got some messages, that's OK (server might not send 404 after exact batch)
491
- # If we got nothing, this is a network problem
492
- if not got_any_message and len(msgs) == 0:
493
- log.warning(f"{self} Timeout after {timeout}s waiting for server response (network issue?)")
494
- else:
495
- log.debug(f"{self} Timeout after getting {len(msgs)} messages (no 404 received)")
496
- break
497
-
498
- try:
499
- # Wait for server to respond (messages or 404)
500
- msg = await asyncio.wait_for(queue.get(), timeout=deadline)
501
- pull_subscription._sub._pending_size -= len(msg.data)
502
- queue.task_done()
503
498
 
504
- status = JetStreamContext.is_status_msg(msg)
505
-
506
- if status == "404":
507
- # Server says: no (more) messages available
508
- # This is the definitive "done" signal
509
- log.debug(f"{self} Server confirmed no more messages (404 status)")
510
- got_404_status = True
511
- break
512
- elif status == "408":
513
- # Request Timeout - server gave us what it had but couldn't fill batch
514
- # This is effectively "no more messages available right now"
515
- log.debug(f"{self} Server returned partial batch (408 status - request timeout)")
516
- got_404_status = True # Treat same as 404 - no more messages available
517
- break
518
- elif status:
519
- # Other status message (shouldn't happen with pull, but handle it)
520
- log.debug(f"{self} Received status: {status}")
521
- continue
522
- else:
523
- # Real message
524
- msgs.append(msg)
525
- got_any_message = True
526
- needed -= 1
499
+ def log_backstop():
500
+ if msgs:
501
+ log.warning(f"{self} No end-of-data signal within {timeout}s backstop, "
502
+ f"returning {len(msgs)} messages (no num_pending metadata? network issue?)")
503
+ else:
504
+ log.warning(f"{self} Timeout after {timeout}s waiting for server response (network issue?)")
527
505
 
528
- # If we got all requested messages, give server very short time to send 404
529
- # but don't wait long - likely there are more messages
530
- if needed == 0:
531
- # Very short grace period for 404 status (100ms)
532
- # If 404 doesn't arrive, we'll just fetch again on next iteration
533
- remaining_time = timeout - (time.monotonic() - start_time)
534
- if remaining_time > 0.1:
535
- deadline = 0.1
536
- timeout = time.monotonic() - start_time + 0.1 # Update timeout to exit on next deadline check
506
+ while True:
507
+ remaining = timeout - (time.monotonic() - start_time)
508
+ if remaining <= 0:
509
+ log_backstop()
510
+ break
537
511
 
512
+ try:
513
+ msg = await asyncio.wait_for(queue.get(), timeout=remaining)
538
514
  except asyncio.TimeoutError:
539
- # Timeout after getting messages is OK - server might not send 404 if we got exact batch
540
- if got_any_message or len(msgs) > 0:
541
- log.debug(f"{self} Got {len(msgs)} messages, no 404 status (normal for exact batch)")
542
- else:
543
- # No messages at all - this could be empty subject or network problem
544
- log.debug(f"{self} No messages or 404 within {timeout}s")
515
+ log_backstop()
545
516
  break
546
517
  except Exception as e:
547
518
  log.warning(f"{self} Error in fetch_available: {e}")
548
519
  break
520
+ pull_subscription._sub._pending_size -= len(msg.data)
521
+ queue.task_done()
522
+
523
+ status = JetStreamContext.is_status_msg(msg)
524
+ if status == "404":
525
+ # Server says: no messages available at all
526
+ log.debug(f"{self} Server confirmed no messages (404 status)")
527
+ break
528
+ elif status == "408":
529
+ # Request Timeout - server closed the request after a partial batch
530
+ log.debug(f"{self} Server closed partial batch (408 status)")
531
+ break
532
+ elif status:
533
+ # Other status message (shouldn't happen with pull, but handle it)
534
+ log.debug(f"{self} Received status: {status}")
535
+ continue
536
+
537
+ # Real message
538
+ msgs.append(msg)
539
+ needed -= 1
540
+
541
+ num_pending = self._num_pending(msg)
542
+ if num_pending == 0:
543
+ # Server-confirmed end of data - no reason to wait for the
544
+ # closing status (which may trail by a network round trip,
545
+ # or never come for an exact-batch response)
546
+ log.debug(f"{self} Server reports num_pending=0, returning {len(msgs)} messages")
547
+ break
548
+ if needed == 0:
549
+ # Batch full and the server has more - return immediately,
550
+ # the caller decides whether to pull again
551
+ log.debug(f"{self} Batch of {len(msgs)} filled, "
552
+ f"server reports {num_pending} message(s) still pending")
553
+ break
554
+ # num_pending unknown (no JS metadata): keep reading until a
555
+ # closing status arrives or the backstop hits (degraded mode)
549
556
 
550
557
  return msgs
551
558
 
559
+ @staticmethod
560
+ def _num_pending(msg: Msg) -> int | None:
561
+ """Returns server-side `num_pending` from JetStream message metadata
562
+
563
+ `num_pending` is the number of messages still remaining for this
564
+ consumer after the given message. Returns None if the metadata is
565
+ unavailable (non-JetStream message).
566
+ """
567
+ try:
568
+ return msg.metadata.num_pending
569
+ except Exception:
570
+ return None
571
+
552
572
  async def next_msg(self, timeout: Optional[float] = None) -> Msg:
553
573
  """ [NATS fix] Fetch the next message from the subscription
554
574
  :params timeout: Time in seconds to wait for next message before timing out.
@@ -1,41 +0,0 @@
1
- class MessengerCannotConnect(Exception):
2
- pass
3
-
4
- class MessengerNotConnected(Exception):
5
- pass
6
-
7
- class MessengerReaderStopped(Exception):
8
- pass
9
-
10
- class MessengerReaderConfigError(ValueError):
11
- """Raised when a MsgReader is misconfigured (e.g. missing required start marker).
12
-
13
- This is a fatal error — the same configuration will never succeed against
14
- the NATS server. The reader stops and this exception propagates out of
15
- ``async for`` loops so callers fail loudly instead of retrying forever.
16
- """
17
- pass
18
-
19
- class MessengerReaderAlreadyOpen(RuntimeError):
20
- pass
21
-
22
- class MessengerRequestNoResponse(Exception):
23
- pass
24
-
25
- class MessengerRequestNoResponders(MessengerRequestNoResponse):
26
- pass
27
-
28
- class MessengerRequestJetStreamSubject(MessengerRequestNoResponse):
29
- def __init__(self, subject:str) -> None:
30
- super().__init__(f'Subject {subject} probably declared in JetStream stream. '
31
- f'Use pure NATS core subjects for RPC')
32
-
33
-
34
- class MessengerRequestCanceled(MessengerRequestNoResponse):
35
- pass
36
-
37
- class MessengerRequestTimeout(MessengerRequestNoResponse, TimeoutError):
38
- pass
39
-
40
- class MessengerRequestNoResultYet(MessengerRequestNoResponse):
41
- pass
File without changes
File without changes
File without changes