serverish 2.0.4__tar.gz → 2.1.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.4 → serverish-2.1.0}/PKG-INFO +1 -1
- {serverish-2.0.4 → serverish-2.1.0}/pyproject.toml +1 -1
- serverish-2.1.0/serverish/base/exceptions.py +64 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/connection/connection_jets.py +28 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/__init__.py +4 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/messenger.py +52 -0
- serverish-2.1.0/serverish/messenger/msg_kv.py +111 -0
- serverish-2.1.0/serverish/messenger/msg_kv_read.py +78 -0
- serverish-2.1.0/serverish/messenger/msg_kv_store.py +141 -0
- serverish-2.1.0/serverish/messenger/msg_kv_sub.py +111 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_reader.py +24 -1
- serverish-2.0.4/serverish/base/exceptions.py +0 -32
- {serverish-2.0.4 → serverish-2.1.0}/LICENSE +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/README.md +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/__init__.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/__init__.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/asyncio_util_functions.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/collector.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/datetime.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/fifoset.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/hasstatuses.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/idmanger.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/iterators.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/manageable.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/singleton.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/status.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/base/task_manager.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/connection/__init__.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/connection/connection.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/connection/connection_nats.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/live_document.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/logging.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_callback_sub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_cmd_pub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_cmd_sub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_core_pub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_core_read.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_core_sub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_document_read.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_journal_pub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_journal_read.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_progress_pub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_progress_read.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_publisher.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_rpc_req.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_rpc_resp.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_single_pub.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msg_single_read.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/messenger/msgvalidator.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/monitoring/__init__.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/monitoring/bridge.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/monitoring/factory.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/monitoring/monitored.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/monitoring/monitored_nats.py +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/config.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/default.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/include/timestamp.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/journal.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/meta.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/program_current_state.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/program_state.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/progress.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/schema/telemetry.schema.json +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/services/TODO.txt +0 -0
- {serverish-2.0.4 → serverish-2.1.0}/serverish/slog.py +0 -0
|
@@ -0,0 +1,64 @@
|
|
|
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
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class MessengerKvError(Exception):
|
|
45
|
+
pass
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class MessengerKvBucketNotFound(MessengerKvError):
|
|
49
|
+
"""Raised when a KV bucket does not exist and the driver was not allowed to create it."""
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class MessengerKvKeyNotFound(MessengerKvError, KeyError):
|
|
54
|
+
pass
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class MessengerKvMalformed(MessengerKvError):
|
|
58
|
+
"""Raised when a KV entry does not carry a serverish envelope.
|
|
59
|
+
|
|
60
|
+
Serverish stores full ``{"data": ..., "meta": ...}`` envelopes in KV buckets.
|
|
61
|
+
An entry written by a non-serverish client fails loudly instead of being
|
|
62
|
+
silently returned as empty.
|
|
63
|
+
"""
|
|
64
|
+
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)
|
|
@@ -12,12 +12,14 @@ from uuid import uuid4
|
|
|
12
12
|
|
|
13
13
|
|
|
14
14
|
import nats.errors
|
|
15
|
+
import nats.js.errors
|
|
15
16
|
import param
|
|
16
17
|
from nats.js import JetStreamContext
|
|
17
18
|
from nats.js.api import DeliverPolicy, ConsumerConfig
|
|
18
19
|
|
|
19
20
|
from serverish.base import wait_for_psce
|
|
20
|
-
from serverish.base.exceptions import (MessengerReaderStopped,
|
|
21
|
+
from serverish.base.exceptions import (MessengerReaderStopped, MessengerReaderConfigError,
|
|
22
|
+
MessengerNotConnected,
|
|
21
23
|
MessengerReaderAlreadyOpen, MessengerRequestTimeout)
|
|
22
24
|
from serverish.base.fifoset import FifoSet
|
|
23
25
|
from serverish.messenger import Messenger
|
|
@@ -105,6 +107,17 @@ class MsgReader(MsgDriver):
|
|
|
105
107
|
super().__init__(subject=subject, parent=parent,
|
|
106
108
|
deliver_policy=deliver_policy, opt_start_time=opt_start_time, consumer_cfg=consumer_cfg_defaults,
|
|
107
109
|
**kwargs)
|
|
110
|
+
# Validate deliver_policy ↔ start-marker consistency up front so
|
|
111
|
+
# callers get a clear error at construction time rather than an
|
|
112
|
+
# opaque NATS 400 error buried inside the read loop.
|
|
113
|
+
if self.deliver_policy == 'by_start_time' and self.opt_start_time is None:
|
|
114
|
+
raise ValueError(
|
|
115
|
+
"deliver_policy='by_start_time' requires opt_start_time to be set"
|
|
116
|
+
)
|
|
117
|
+
if self.deliver_policy == 'by_start_sequence' and self.consumer_cfg.get('opt_start_seq') is None:
|
|
118
|
+
raise ValueError(
|
|
119
|
+
"deliver_policy='by_start_sequence' requires opt_start_seq to be set in consumer_cfg"
|
|
120
|
+
)
|
|
108
121
|
log.debug(f"Created {self}")
|
|
109
122
|
|
|
110
123
|
|
|
@@ -384,6 +397,16 @@ class MsgReader(MsgDriver):
|
|
|
384
397
|
st.logput(f'{e.task}-err')
|
|
385
398
|
st.error = e.error
|
|
386
399
|
self._last_error = e.error # Track for health monitoring
|
|
400
|
+
# Fatal NATS API errors (4xx): the same request will never
|
|
401
|
+
# succeed — stop retrying immediately and surface a clear
|
|
402
|
+
# MessengerReaderConfigError to the caller.
|
|
403
|
+
if isinstance(e.error, (nats.js.errors.BadRequestError,
|
|
404
|
+
nats.js.errors.NotFoundError)):
|
|
405
|
+
log.error(st.fmt(f"fatal NATS error (not retrying): {e.error}"))
|
|
406
|
+
self._stop.set()
|
|
407
|
+
raise MessengerReaderConfigError(
|
|
408
|
+
f"Fatal NATS error on {self.subject}: {e.error}"
|
|
409
|
+
) from e.error
|
|
387
410
|
match self.error_behavior:
|
|
388
411
|
case 'RAISE':
|
|
389
412
|
log.error(st.fmt(f"raising read_next error: {e.error}"))
|
|
@@ -1,32 +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 MessengerReaderAlreadyOpen(RuntimeError):
|
|
11
|
-
pass
|
|
12
|
-
|
|
13
|
-
class MessengerRequestNoResponse(Exception):
|
|
14
|
-
pass
|
|
15
|
-
|
|
16
|
-
class MessengerRequestNoResponders(MessengerRequestNoResponse):
|
|
17
|
-
pass
|
|
18
|
-
|
|
19
|
-
class MessengerRequestJetStreamSubject(MessengerRequestNoResponse):
|
|
20
|
-
def __init__(self, subject:str) -> None:
|
|
21
|
-
super().__init__(f'Subject {subject} probably declared in JetStream stream. '
|
|
22
|
-
f'Use pure NATS core subjects for RPC')
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
class MessengerRequestCanceled(MessengerRequestNoResponse):
|
|
26
|
-
pass
|
|
27
|
-
|
|
28
|
-
class MessengerRequestTimeout(MessengerRequestNoResponse, TimeoutError):
|
|
29
|
-
pass
|
|
30
|
-
|
|
31
|
-
class MessengerRequestNoResultYet(MessengerRequestNoResponse):
|
|
32
|
-
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
|
|
File without changes
|