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.
- {serverish-2.0.5 → serverish-2.2.0}/PKG-INFO +2 -2
- {serverish-2.0.5 → serverish-2.2.0}/pyproject.toml +5 -5
- serverish-2.2.0/serverish/base/exceptions.py +87 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/connection_jets.py +28 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/__init__.py +4 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/messenger.py +52 -0
- serverish-2.2.0/serverish/messenger/msg_kv.py +111 -0
- serverish-2.2.0/serverish/messenger/msg_kv_read.py +78 -0
- serverish-2.2.0/serverish/messenger/msg_kv_store.py +141 -0
- serverish-2.2.0/serverish/messenger/msg_kv_sub.py +111 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_publisher.py +45 -2
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_reader.py +83 -63
- serverish-2.0.5/serverish/base/exceptions.py +0 -41
- {serverish-2.0.5 → serverish-2.2.0}/LICENSE +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/README.md +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/__init__.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/__init__.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/asyncio_util_functions.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/collector.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/datetime.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/fifoset.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/hasstatuses.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/idmanger.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/iterators.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/manageable.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/singleton.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/status.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/base/task_manager.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/__init__.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/connection.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/connection/connection_nats.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/live_document.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/logging.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_callback_sub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_cmd_pub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_cmd_sub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_core_pub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_core_read.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_core_sub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_document_read.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_journal_pub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_journal_read.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_progress_pub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_progress_read.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_rpc_req.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_rpc_resp.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_single_pub.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msg_single_read.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/messenger/msgvalidator.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/__init__.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/bridge.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/factory.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/monitored.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/monitoring/monitored_nats.py +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/config.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/default.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/include/timestamp.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/journal.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/meta.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/program_current_state.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/program_state.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/progress.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/schema/telemetry.schema.json +0 -0
- {serverish-2.0.5 → serverish-2.2.0}/serverish/services/TODO.txt +0 -0
- {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
|
|
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
|
|
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
|
|
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="
|
|
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 = ">=
|
|
24
|
-
pytest-asyncio = "
|
|
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 = "
|
|
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.
|
|
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
|
-
|
|
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
|
|
443
|
-
NOT a "wait for messages" timeout
|
|
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
|
|
481
|
-
#
|
|
482
|
-
#
|
|
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
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|