serverish 1.4.2__tar.gz → 1.5.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-1.4.2 → serverish-1.5.0}/PKG-INFO +3 -1
- {serverish-1.4.2 → serverish-1.5.0}/README.md +2 -0
- {serverish-1.4.2 → serverish-1.5.0}/pyproject.toml +1 -1
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/__init__.py +2 -0
- serverish-1.5.0/serverish/messenger/live_document.py +300 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/messenger.py +48 -2
- serverish-1.5.0/serverish/messenger/msg_document_read.py +282 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_reader.py +96 -30
- {serverish-1.4.2 → serverish-1.5.0}/LICENSE +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/__init__.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/__init__.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/asyncio_util_functions.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/collector.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/datetime.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/exceptions.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/fifoset.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/hasstatuses.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/idmanger.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/iterators.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/manageable.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/singleton.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/status.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/base/task_manager.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/__init__.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/connection.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/connection_jets.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/connection_nats.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/logging.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_callback_sub.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_journal_pub.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_journal_read.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_progress_pub.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_progress_read.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_publisher.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_rpc_req.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_rpc_resp.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_single_pub.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_single_read.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msgvalidator.py +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/config.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/default.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/include/timestamp.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/journal.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/meta.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/program_current_state.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/program_state.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/progress.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/telemetry.schema.json +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/services/TODO.txt +0 -0
- {serverish-1.4.2 → serverish-1.5.0}/serverish/slog.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.1
|
|
2
2
|
Name: serverish
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.5.0
|
|
4
4
|
Summary: helpers for server alike projects
|
|
5
5
|
Home-page: https://github.com/AkondLab/serverish
|
|
6
6
|
Author: Mikołaj Kałuszyński
|
|
@@ -26,6 +26,7 @@ Description-Content-Type: text/markdown
|
|
|
26
26
|
* Singletons
|
|
27
27
|
* Connections management and diagnostics
|
|
28
28
|
* NATS based Messenger
|
|
29
|
+
* Live documents - auto-updating configuration from NATS
|
|
29
30
|
|
|
30
31
|
See [`doc` directory](doc/) for more documentation.
|
|
31
32
|
|
|
@@ -47,6 +48,7 @@ this will install `nats-py` package.
|
|
|
47
48
|
- Pytest fixtures for NATS on CI
|
|
48
49
|
|
|
49
50
|
## Changes
|
|
51
|
+
* 1.5 Introduces live documents (`LiveDocument`, `get_documentreader()`, `get_live_document()`) for auto-updating configuration from NATS.
|
|
50
52
|
* 1.4 Contains improvements to the network problems recovery and connection tracking.
|
|
51
53
|
* 1.1 Switches to `param` 2.* nad `py-nats` 1.7.*. Also, publisher may reraise exceptions or ignore them.
|
|
52
54
|
* 1.0 Changes NATS subscriptions to pull only
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* Singletons
|
|
7
7
|
* Connections management and diagnostics
|
|
8
8
|
* NATS based Messenger
|
|
9
|
+
* Live documents - auto-updating configuration from NATS
|
|
9
10
|
|
|
10
11
|
See [`doc` directory](doc/) for more documentation.
|
|
11
12
|
|
|
@@ -27,6 +28,7 @@ this will install `nats-py` package.
|
|
|
27
28
|
- Pytest fixtures for NATS on CI
|
|
28
29
|
|
|
29
30
|
## Changes
|
|
31
|
+
* 1.5 Introduces live documents (`LiveDocument`, `get_documentreader()`, `get_live_document()`) for auto-updating configuration from NATS.
|
|
30
32
|
* 1.4 Contains improvements to the network problems recovery and connection tracking.
|
|
31
33
|
* 1.1 Switches to `param` 2.* nad `py-nats` 1.7.*. Also, publisher may reraise exceptions or ignore them.
|
|
32
34
|
* 1.0 Changes NATS subscriptions to pull only
|
|
@@ -11,4 +11,6 @@ from .msg_progress_pub import get_progresspublisher, ProgressTask
|
|
|
11
11
|
from .msg_progress_read import get_progressreader
|
|
12
12
|
from .msg_journal_pub import get_journalpublisher, JournalEntry
|
|
13
13
|
from .msg_journal_read import get_journalreader
|
|
14
|
+
from .msg_document_read import get_documentreader, get_live_document, MsgDocumentReader
|
|
15
|
+
from .live_document import LiveDocument
|
|
14
16
|
from .logging import NatsJournalLoggingHandler
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
"""Live document that auto-updates when underlying data changes.
|
|
2
|
+
|
|
3
|
+
This module provides LiveDocument, a dict-like object that can be updated
|
|
4
|
+
asynchronously and propagates changes through a hierarchical tree structure.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import asyncio
|
|
8
|
+
import copy
|
|
9
|
+
import logging
|
|
10
|
+
from collections.abc import Mapping
|
|
11
|
+
from typing import Any, Callable, Iterator
|
|
12
|
+
|
|
13
|
+
log = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class LiveDocument(Mapping):
|
|
17
|
+
"""A dict-like object that auto-updates when the underlying document changes.
|
|
18
|
+
|
|
19
|
+
Implements collections.abc.Mapping protocol for full dict compatibility.
|
|
20
|
+
Supports both dictionary-style and namespace-style access:
|
|
21
|
+
- doc['database']['host'] # dict-style
|
|
22
|
+
- doc.database.host # namespace-style
|
|
23
|
+
|
|
24
|
+
LiveDocument creates cached subtrees for nested dicts, ensuring that
|
|
25
|
+
when the root document updates, all subtrees also update automatically.
|
|
26
|
+
|
|
27
|
+
Attributes:
|
|
28
|
+
_data: The underlying dictionary data (private)
|
|
29
|
+
_parent: Parent LiveDocument (None for root, private)
|
|
30
|
+
_path: Dot-separated path from root (e.g., "database.connection")
|
|
31
|
+
_children: Cached LiveDocument instances for dict values (private)
|
|
32
|
+
_callbacks: List of (callback, deep) tuples for change notifications (private)
|
|
33
|
+
_lock: Async lock for thread-safe updates (private)
|
|
34
|
+
_version: Incremented on each update (access as attribute, not property)
|
|
35
|
+
_reader: Reference to MsgDocumentReader for lifecycle management (private, root only)
|
|
36
|
+
|
|
37
|
+
Example:
|
|
38
|
+
>>> doc = LiveDocument({'database': {'host': 'localhost', 'port': 5432}})
|
|
39
|
+
>>> db = doc['database'] # Returns cached LiveDocument
|
|
40
|
+
>>> db.host # Returns 'localhost'
|
|
41
|
+
>>> await doc._update({'database': {'host': 'newhost', 'port': 5432}})
|
|
42
|
+
>>> db.host # Returns 'newhost' - subtree was updated!
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(
|
|
46
|
+
self,
|
|
47
|
+
data: dict,
|
|
48
|
+
parent: 'LiveDocument | None' = None,
|
|
49
|
+
path: str = ''
|
|
50
|
+
):
|
|
51
|
+
"""Initialize a LiveDocument.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
data: Dictionary data to wrap
|
|
55
|
+
parent: Parent LiveDocument if this is a subtree
|
|
56
|
+
path: Dot-separated path from root (e.g., "database.connection")
|
|
57
|
+
"""
|
|
58
|
+
self._data = data
|
|
59
|
+
self._parent = parent
|
|
60
|
+
self._path = path
|
|
61
|
+
self._children: dict[str, LiveDocument] = {}
|
|
62
|
+
self._callbacks: list[tuple[Callable, bool]] = []
|
|
63
|
+
self._lock = asyncio.Lock()
|
|
64
|
+
self._version = 0
|
|
65
|
+
self._reader = None # Will be set by get_live_document() for root document
|
|
66
|
+
|
|
67
|
+
# Mapping protocol implementation (required methods)
|
|
68
|
+
|
|
69
|
+
def __getitem__(self, key: str) -> Any:
|
|
70
|
+
"""Get item - returns LiveDocument for dicts, raw value otherwise.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
key: Dictionary key to retrieve
|
|
74
|
+
|
|
75
|
+
Returns:
|
|
76
|
+
LiveDocument for nested dicts, raw value otherwise
|
|
77
|
+
|
|
78
|
+
Raises:
|
|
79
|
+
KeyError: If key does not exist
|
|
80
|
+
"""
|
|
81
|
+
value = self._data[key]
|
|
82
|
+
if isinstance(value, dict):
|
|
83
|
+
# Return CACHED LiveDocument - same instance every time
|
|
84
|
+
if key not in self._children:
|
|
85
|
+
new_path = f'{self._path}.{key}' if self._path else key
|
|
86
|
+
self._children[key] = LiveDocument(value, parent=self, path=new_path)
|
|
87
|
+
return self._children[key]
|
|
88
|
+
return value
|
|
89
|
+
|
|
90
|
+
def __iter__(self) -> Iterator[str]:
|
|
91
|
+
"""Iterate over keys.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
Iterator over dictionary keys
|
|
95
|
+
"""
|
|
96
|
+
return iter(self._data)
|
|
97
|
+
|
|
98
|
+
def __len__(self) -> int:
|
|
99
|
+
"""Number of keys.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Number of keys in the document
|
|
103
|
+
"""
|
|
104
|
+
return len(self._data)
|
|
105
|
+
|
|
106
|
+
# Additional dict-like methods (Mapping provides defaults, but we optimize)
|
|
107
|
+
|
|
108
|
+
def get(self, key: str, default: Any = None) -> Any:
|
|
109
|
+
"""Get with default value.
|
|
110
|
+
|
|
111
|
+
Args:
|
|
112
|
+
key: Dictionary key to retrieve
|
|
113
|
+
default: Default value if key not found
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Value for key, or default if not found
|
|
117
|
+
"""
|
|
118
|
+
try:
|
|
119
|
+
return self[key]
|
|
120
|
+
except KeyError:
|
|
121
|
+
return default
|
|
122
|
+
|
|
123
|
+
def __contains__(self, key: object) -> bool:
|
|
124
|
+
"""Check if key exists.
|
|
125
|
+
|
|
126
|
+
Args:
|
|
127
|
+
key: Key to check
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
True if key exists in the document
|
|
131
|
+
"""
|
|
132
|
+
return key in self._data
|
|
133
|
+
|
|
134
|
+
# Namespace-like access
|
|
135
|
+
|
|
136
|
+
def __getattr__(self, key: str) -> Any:
|
|
137
|
+
"""Enable doc.database.host syntax.
|
|
138
|
+
|
|
139
|
+
Args:
|
|
140
|
+
key: Attribute name to retrieve
|
|
141
|
+
|
|
142
|
+
Returns:
|
|
143
|
+
Value for the attribute
|
|
144
|
+
|
|
145
|
+
Raises:
|
|
146
|
+
AttributeError: If attribute does not exist
|
|
147
|
+
"""
|
|
148
|
+
if key.startswith('_'):
|
|
149
|
+
# Private attributes - use normal lookup
|
|
150
|
+
return object.__getattribute__(self, key)
|
|
151
|
+
try:
|
|
152
|
+
return self[key]
|
|
153
|
+
except KeyError:
|
|
154
|
+
raise AttributeError(
|
|
155
|
+
f"'{type(self).__name__}' object has no attribute '{key}'"
|
|
156
|
+
) from None
|
|
157
|
+
|
|
158
|
+
def __dir__(self):
|
|
159
|
+
"""Return list of attributes for autocomplete/introspection.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
List of keys plus standard attributes
|
|
163
|
+
"""
|
|
164
|
+
# Include both dict keys and standard attributes
|
|
165
|
+
return list(self._data.keys()) + [
|
|
166
|
+
attr for attr in object.__dir__(self)
|
|
167
|
+
if not attr.startswith('_')
|
|
168
|
+
]
|
|
169
|
+
|
|
170
|
+
# Update mechanism
|
|
171
|
+
|
|
172
|
+
async def _update(self, new_data: dict):
|
|
173
|
+
"""Update the document data and propagate to children.
|
|
174
|
+
|
|
175
|
+
This method is called internally when the document changes.
|
|
176
|
+
It updates the local data, propagates changes to all cached
|
|
177
|
+
children, and triggers any registered callbacks.
|
|
178
|
+
|
|
179
|
+
Args:
|
|
180
|
+
new_data: New dictionary data to replace current data
|
|
181
|
+
"""
|
|
182
|
+
async with self._lock:
|
|
183
|
+
old_data = copy.deepcopy(self._data)
|
|
184
|
+
self._data = copy.deepcopy(new_data)
|
|
185
|
+
self._version += 1
|
|
186
|
+
|
|
187
|
+
# Update all cached children recursively
|
|
188
|
+
for key, child in list(self._children.items()):
|
|
189
|
+
if key in new_data and isinstance(new_data[key], dict):
|
|
190
|
+
# Key still exists and is still a dict - update it
|
|
191
|
+
await child._update(new_data[key])
|
|
192
|
+
elif key in new_data:
|
|
193
|
+
# Key exists but is no longer a dict - remove from children
|
|
194
|
+
# It will be recreated as needed
|
|
195
|
+
del self._children[key]
|
|
196
|
+
else:
|
|
197
|
+
# Key was removed - update child to empty dict
|
|
198
|
+
await child._update({})
|
|
199
|
+
|
|
200
|
+
# Trigger callbacks
|
|
201
|
+
await self._trigger_callbacks(old_data, new_data)
|
|
202
|
+
|
|
203
|
+
# Callback support
|
|
204
|
+
|
|
205
|
+
def on_change(
|
|
206
|
+
self,
|
|
207
|
+
callback: Callable[[dict, dict], Any],
|
|
208
|
+
deep: bool = True
|
|
209
|
+
) -> Callable:
|
|
210
|
+
"""Register callback for changes in this subtree.
|
|
211
|
+
|
|
212
|
+
Args:
|
|
213
|
+
callback: Async function(old_value, new_value) -> None
|
|
214
|
+
deep: If True, trigger on any nested change; if False, only direct changes
|
|
215
|
+
|
|
216
|
+
Returns:
|
|
217
|
+
The callback (allows use as decorator)
|
|
218
|
+
|
|
219
|
+
Example:
|
|
220
|
+
>>> async def on_db_change(old, new):
|
|
221
|
+
... print(f"Database config changed: {old} -> {new}")
|
|
222
|
+
>>> doc.database.on_change(on_db_change)
|
|
223
|
+
|
|
224
|
+
Or as a decorator:
|
|
225
|
+
>>> @doc.database.on_change
|
|
226
|
+
... async def on_db_change(old, new):
|
|
227
|
+
... print(f"Database config changed: {old} -> {new}")
|
|
228
|
+
"""
|
|
229
|
+
self._callbacks.append((callback, deep))
|
|
230
|
+
return callback
|
|
231
|
+
|
|
232
|
+
def remove_callback(self, callback: Callable):
|
|
233
|
+
"""Remove a registered callback.
|
|
234
|
+
|
|
235
|
+
Args:
|
|
236
|
+
callback: The callback function to remove
|
|
237
|
+
"""
|
|
238
|
+
self._callbacks = [
|
|
239
|
+
(cb, deep) for cb, deep in self._callbacks
|
|
240
|
+
if cb != callback
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
async def _trigger_callbacks(self, old_data: dict, new_data: dict):
|
|
244
|
+
"""Trigger all registered callbacks.
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
old_data: Previous data value
|
|
248
|
+
new_data: New data value
|
|
249
|
+
"""
|
|
250
|
+
for callback, deep in self._callbacks:
|
|
251
|
+
try:
|
|
252
|
+
# Check if callback is async
|
|
253
|
+
if asyncio.iscoroutinefunction(callback):
|
|
254
|
+
await callback(old_data, new_data)
|
|
255
|
+
else:
|
|
256
|
+
callback(old_data, new_data)
|
|
257
|
+
except Exception as e:
|
|
258
|
+
log.error(
|
|
259
|
+
f"Callback error in {self._path or 'root'}: {e}",
|
|
260
|
+
exc_info=True
|
|
261
|
+
)
|
|
262
|
+
|
|
263
|
+
# Utility methods
|
|
264
|
+
|
|
265
|
+
def to_dict(self) -> dict:
|
|
266
|
+
"""Convert to a plain dictionary (deep copy).
|
|
267
|
+
|
|
268
|
+
Returns:
|
|
269
|
+
Deep copy of the underlying data
|
|
270
|
+
"""
|
|
271
|
+
return copy.deepcopy(self._data)
|
|
272
|
+
|
|
273
|
+
def snapshot(self) -> 'LiveDocument':
|
|
274
|
+
"""Create a point-in-time snapshot (not live).
|
|
275
|
+
|
|
276
|
+
Returns:
|
|
277
|
+
New LiveDocument with copied data, no parent/children links
|
|
278
|
+
"""
|
|
279
|
+
return LiveDocument(copy.deepcopy(self._data))
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
# Representation
|
|
283
|
+
|
|
284
|
+
def __repr__(self) -> str:
|
|
285
|
+
"""String representation.
|
|
286
|
+
|
|
287
|
+
Returns:
|
|
288
|
+
String representation of the document
|
|
289
|
+
"""
|
|
290
|
+
if self._path:
|
|
291
|
+
return f"LiveDocument(path='{self._path}', data={self._data!r})"
|
|
292
|
+
return f"LiveDocument({self._data!r})"
|
|
293
|
+
|
|
294
|
+
def __str__(self) -> str:
|
|
295
|
+
"""User-friendly string representation.
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
String representation of the underlying data
|
|
299
|
+
"""
|
|
300
|
+
return str(self._data)
|
|
@@ -340,21 +340,36 @@ class Messenger(Singleton):
|
|
|
340
340
|
deliver_policy (str): deliver policy, one of 'all', 'last', 'new', 'by_start_time', will be passed to consumer config
|
|
341
341
|
opt_start_time (datetime): start time for 'by_start_time' deliver policy, will be passed to consumer config, must carry TZ info
|
|
342
342
|
kwargs: additional arguments to pass to the reader and underlying NATS consumer config
|
|
343
|
+
MsgReader-specific params (nowait, error_behavior, on_missed_messages) are extracted
|
|
344
|
+
and passed directly to MsgReader; remaining params go to consumer_cfg
|
|
343
345
|
|
|
344
346
|
Returns:
|
|
345
347
|
MsgReader: message subscriber
|
|
346
348
|
|
|
347
349
|
Usage:
|
|
348
|
-
reader = Messenger.get_reader('subject'):
|
|
350
|
+
reader = Messenger.get_reader('subject', nowait=True):
|
|
349
351
|
async for msg in reader:
|
|
350
352
|
print(msg)
|
|
351
353
|
"""
|
|
352
354
|
from serverish.messenger.msg_reader import MsgReader
|
|
355
|
+
|
|
356
|
+
# Extract MsgReader-specific parameters
|
|
357
|
+
reader_params = {}
|
|
358
|
+
consumer_cfg = {}
|
|
359
|
+
reader_param_names = {'nowait', 'error_behavior', 'on_missed_messages'}
|
|
360
|
+
|
|
361
|
+
for key, value in kwargs.items():
|
|
362
|
+
if key in reader_param_names:
|
|
363
|
+
reader_params[key] = value
|
|
364
|
+
else:
|
|
365
|
+
consumer_cfg[key] = value
|
|
366
|
+
|
|
353
367
|
return MsgReader(subject=subject,
|
|
354
368
|
parent=Messenger(),
|
|
355
369
|
deliver_policy=deliver_policy,
|
|
356
370
|
opt_start_time=opt_start_time,
|
|
357
|
-
consumer_cfg=
|
|
371
|
+
consumer_cfg=consumer_cfg,
|
|
372
|
+
**reader_params)
|
|
358
373
|
|
|
359
374
|
@staticmethod
|
|
360
375
|
def get_singlepublisher(subject):
|
|
@@ -510,6 +525,37 @@ class Messenger(Singleton):
|
|
|
510
525
|
deliver_policy=deliver_policy,
|
|
511
526
|
**kwargs)
|
|
512
527
|
|
|
528
|
+
@staticmethod
|
|
529
|
+
def get_documentreader(subject: str,
|
|
530
|
+
initial_wait: float = 5.0,
|
|
531
|
+
**kwargs) -> 'MsgDocumentReader':
|
|
532
|
+
"""Returns a document reader for a given subject
|
|
533
|
+
|
|
534
|
+
The document reader maintains a live document that auto-updates when
|
|
535
|
+
new versions are published to the subject. It reads the initial document
|
|
536
|
+
on open() and subscribes to updates in the background.
|
|
537
|
+
|
|
538
|
+
Args:
|
|
539
|
+
subject (str): subject to read from
|
|
540
|
+
initial_wait (float): timeout in seconds for initial document read
|
|
541
|
+
**kwargs: additional arguments to pass to the reader and underlying NATS consumer config
|
|
542
|
+
|
|
543
|
+
Returns:
|
|
544
|
+
MsgDocumentReader: a document reader for the given subject
|
|
545
|
+
|
|
546
|
+
Example:
|
|
547
|
+
reader = Messenger.get_documentreader('app.config')
|
|
548
|
+
await reader.open()
|
|
549
|
+
host = reader.document.database.host
|
|
550
|
+
await reader.close()
|
|
551
|
+
|
|
552
|
+
"""
|
|
553
|
+
from serverish.messenger.msg_document_read import MsgDocumentReader
|
|
554
|
+
return MsgDocumentReader(subject=subject,
|
|
555
|
+
parent=Messenger(),
|
|
556
|
+
initial_wait=initial_wait,
|
|
557
|
+
**kwargs)
|
|
558
|
+
|
|
513
559
|
|
|
514
560
|
class MsgDriver(Manageable):
|
|
515
561
|
subject: str = param.String(default=None, allow_None=True, doc="User subject to publish to, prefix may be added")
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Document reader for live configuration documents.
|
|
2
|
+
|
|
3
|
+
This module provides MsgDocumentReader, a specialized MsgReader that
|
|
4
|
+
maintains a live document that auto-updates when new versions are published
|
|
5
|
+
to NATS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
import logging
|
|
12
|
+
from typing import TYPE_CHECKING
|
|
13
|
+
|
|
14
|
+
import param
|
|
15
|
+
|
|
16
|
+
from serverish.base import create_task
|
|
17
|
+
from serverish.messenger.live_document import LiveDocument
|
|
18
|
+
from serverish.messenger.msg_reader import MsgReader, MessengerReaderStopped
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from serverish.base import Task
|
|
22
|
+
from serverish.base.manageable import Manageable
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__.rsplit('.')[-1])
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MsgDocumentReader(MsgReader):
|
|
28
|
+
"""Reads a document from NATS and keeps it updated.
|
|
29
|
+
|
|
30
|
+
This class extends MsgReader to provide a live document that automatically
|
|
31
|
+
updates when new versions are published to the NATS subject. It reads the
|
|
32
|
+
initial document on open() and then subscribes to updates in the background.
|
|
33
|
+
|
|
34
|
+
The document is exposed as a LiveDocument instance, supporting both
|
|
35
|
+
dict-style and namespace-style access.
|
|
36
|
+
|
|
37
|
+
Supports both explicit open/close and async context manager patterns.
|
|
38
|
+
Integrates with Manageable tree for automatic lifecycle management.
|
|
39
|
+
|
|
40
|
+
Attributes:
|
|
41
|
+
document: LiveDocument instance containing the current document state
|
|
42
|
+
initial_wait: Timeout (in seconds) for initial document read
|
|
43
|
+
|
|
44
|
+
Example:
|
|
45
|
+
# Explicit open/close
|
|
46
|
+
reader = get_documentreader('app.config')
|
|
47
|
+
await reader.open()
|
|
48
|
+
host = reader.document.database.host
|
|
49
|
+
await reader.close()
|
|
50
|
+
|
|
51
|
+
# Context manager
|
|
52
|
+
async with get_documentreader('app.config') as reader:
|
|
53
|
+
host = reader.document.database.host
|
|
54
|
+
|
|
55
|
+
# With parent for automatic lifecycle management
|
|
56
|
+
class MyService(Manageable):
|
|
57
|
+
async def open(self):
|
|
58
|
+
self.config_reader = await get_live_document('app.config', parent=self)
|
|
59
|
+
# Auto-closes when MyService closes
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
initial_wait = param.Number(
|
|
63
|
+
default=5.0,
|
|
64
|
+
bounds=(0, None),
|
|
65
|
+
doc="Timeout in seconds for initial document read on open()"
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
def __init__(
|
|
69
|
+
self,
|
|
70
|
+
subject: str,
|
|
71
|
+
parent: Manageable | None = None,
|
|
72
|
+
initial_wait: float = 5.0,
|
|
73
|
+
**kwargs
|
|
74
|
+
):
|
|
75
|
+
"""Initialize a document reader.
|
|
76
|
+
|
|
77
|
+
Args:
|
|
78
|
+
subject: NATS subject to read from
|
|
79
|
+
parent: Parent Manageable for lifecycle management
|
|
80
|
+
initial_wait: Timeout for initial document read (seconds)
|
|
81
|
+
**kwargs: Additional MsgReader options
|
|
82
|
+
"""
|
|
83
|
+
# Force deliver_policy='last' to always get the most recent document
|
|
84
|
+
kwargs['deliver_policy'] = 'last'
|
|
85
|
+
|
|
86
|
+
super().__init__(subject=subject, parent=parent, **kwargs)
|
|
87
|
+
|
|
88
|
+
self.initial_wait = initial_wait
|
|
89
|
+
self.document = LiveDocument({})
|
|
90
|
+
self._update_task: Task | None = None
|
|
91
|
+
self._seq: int | None = None
|
|
92
|
+
|
|
93
|
+
async def open(self) -> None:
|
|
94
|
+
"""Opens connection, reads initial document, starts update loop.
|
|
95
|
+
|
|
96
|
+
This method:
|
|
97
|
+
1. Opens the NATS subscription (via super().open())
|
|
98
|
+
2. Reads the initial document (with timeout)
|
|
99
|
+
3. Starts a background task that listens for updates
|
|
100
|
+
|
|
101
|
+
The background task is managed by the task manager and will
|
|
102
|
+
automatically be cancelled when this reader is closed or when
|
|
103
|
+
the parent Manageable is closed.
|
|
104
|
+
|
|
105
|
+
Raises:
|
|
106
|
+
asyncio.TimeoutError: If no initial document is available within initial_wait
|
|
107
|
+
Note: The error is logged but not raised; continues with empty document
|
|
108
|
+
"""
|
|
109
|
+
await super().open()
|
|
110
|
+
|
|
111
|
+
# Initial read - wait for first document
|
|
112
|
+
try:
|
|
113
|
+
data, meta = await asyncio.wait_for(
|
|
114
|
+
self.read_next(),
|
|
115
|
+
timeout=self.initial_wait
|
|
116
|
+
)
|
|
117
|
+
await self.document._update(data)
|
|
118
|
+
self._seq = meta['nats']['seq']
|
|
119
|
+
log.info(f"Document loaded from {self.subject}: seq={self._seq}")
|
|
120
|
+
except asyncio.TimeoutError:
|
|
121
|
+
log.warning(
|
|
122
|
+
f"No initial document found on {self.subject} "
|
|
123
|
+
f"after {self.initial_wait}s, starting with empty document"
|
|
124
|
+
)
|
|
125
|
+
# Continue with empty document - updates will populate it later
|
|
126
|
+
|
|
127
|
+
# Start background update loop via task manager
|
|
128
|
+
# Task is tracked by TaskManager and can be cancelled via close()
|
|
129
|
+
self._update_task = await create_task(
|
|
130
|
+
self._update_loop(),
|
|
131
|
+
name=f"doc_update_{self.subject}"
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
async def _update_loop(self):
|
|
135
|
+
"""Background task that listens for document updates.
|
|
136
|
+
|
|
137
|
+
This async task runs in the background after open() and continuously
|
|
138
|
+
listens for new document versions published to the NATS subject.
|
|
139
|
+
|
|
140
|
+
It handles:
|
|
141
|
+
- Reading new messages as they arrive
|
|
142
|
+
- Deduplication via sequence numbers
|
|
143
|
+
- Propagating updates to the LiveDocument
|
|
144
|
+
- Graceful shutdown on cancellation or stop signal
|
|
145
|
+
"""
|
|
146
|
+
try:
|
|
147
|
+
async for data, meta in self:
|
|
148
|
+
new_seq = meta['nats']['seq']
|
|
149
|
+
|
|
150
|
+
# Skip duplicates or old versions
|
|
151
|
+
if self._seq and new_seq <= self._seq:
|
|
152
|
+
log.debug(
|
|
153
|
+
f"Skipping duplicate/old document: "
|
|
154
|
+
f"current seq={self._seq}, received seq={new_seq}"
|
|
155
|
+
)
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
log.info(
|
|
159
|
+
f"Document updated on {self.subject}: "
|
|
160
|
+
f"{self._seq} -> {new_seq}"
|
|
161
|
+
)
|
|
162
|
+
await self.document._update(data)
|
|
163
|
+
self._seq = new_seq
|
|
164
|
+
|
|
165
|
+
except MessengerReaderStopped:
|
|
166
|
+
log.info(f"Document reader stopped for {self.subject}")
|
|
167
|
+
except asyncio.CancelledError:
|
|
168
|
+
log.info(f"Document reader cancelled for {self.subject}")
|
|
169
|
+
raise # Re-raise for task manager
|
|
170
|
+
except Exception as e:
|
|
171
|
+
log.error(
|
|
172
|
+
f"Document update loop error for {self.subject}: {e}",
|
|
173
|
+
exc_info=True
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
async def close(self):
|
|
177
|
+
"""Stops update loop and closes connection.
|
|
178
|
+
|
|
179
|
+
Task manager handles cancellation automatically, but we clean up
|
|
180
|
+
explicitly to ensure proper shutdown order.
|
|
181
|
+
"""
|
|
182
|
+
if self._update_task and not self._update_task.done():
|
|
183
|
+
self._update_task.cancel()
|
|
184
|
+
try:
|
|
185
|
+
await self._update_task
|
|
186
|
+
except asyncio.CancelledError:
|
|
187
|
+
pass
|
|
188
|
+
|
|
189
|
+
await super().close()
|
|
190
|
+
|
|
191
|
+
# Async context manager support
|
|
192
|
+
|
|
193
|
+
async def __aenter__(self):
|
|
194
|
+
"""Async context manager entry."""
|
|
195
|
+
await self.open()
|
|
196
|
+
return self
|
|
197
|
+
|
|
198
|
+
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
|
199
|
+
"""Async context manager exit."""
|
|
200
|
+
await self.close()
|
|
201
|
+
return False
|
|
202
|
+
|
|
203
|
+
def __repr__(self) -> str:
|
|
204
|
+
"""String representation."""
|
|
205
|
+
return (
|
|
206
|
+
f"MsgDocumentReader(subject='{self.subject}', "
|
|
207
|
+
f"seq={self._seq}, version={self.document._version})"
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_documentreader(
|
|
212
|
+
subject: str,
|
|
213
|
+
initial_wait: float = 5.0,
|
|
214
|
+
**kwargs
|
|
215
|
+
) -> MsgDocumentReader:
|
|
216
|
+
"""Returns a document reader for the given subject.
|
|
217
|
+
|
|
218
|
+
This is a convenience function that creates a MsgDocumentReader instance.
|
|
219
|
+
The reader must be explicitly opened before use.
|
|
220
|
+
|
|
221
|
+
Args:
|
|
222
|
+
subject: NATS subject to read from
|
|
223
|
+
initial_wait: Timeout for initial document read (seconds)
|
|
224
|
+
**kwargs: Additional MsgReader options
|
|
225
|
+
|
|
226
|
+
Returns:
|
|
227
|
+
MsgDocumentReader: A document reader (not yet opened)
|
|
228
|
+
|
|
229
|
+
Example:
|
|
230
|
+
reader = get_documentreader('app.config')
|
|
231
|
+
await reader.open()
|
|
232
|
+
host = reader.document.database.host
|
|
233
|
+
await reader.close()
|
|
234
|
+
"""
|
|
235
|
+
from serverish.messenger import Messenger
|
|
236
|
+
|
|
237
|
+
return Messenger.get_documentreader(
|
|
238
|
+
subject=subject,
|
|
239
|
+
initial_wait=initial_wait,
|
|
240
|
+
**kwargs
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
async def get_live_document(
|
|
245
|
+
subject: str,
|
|
246
|
+
wait: float = 5.0,
|
|
247
|
+
**kwargs
|
|
248
|
+
) -> LiveDocument:
|
|
249
|
+
"""Get a live document (already opened and auto-updating).
|
|
250
|
+
|
|
251
|
+
This is a convenience function similar to single_read() but for live documents.
|
|
252
|
+
It creates and opens a MsgDocumentReader, then returns the LiveDocument itself.
|
|
253
|
+
The reader is attached to the document for lifecycle management (garbage collection).
|
|
254
|
+
|
|
255
|
+
The reader's lifecycle is managed by serverish task manager. When the document
|
|
256
|
+
is garbage collected, the reader will be cleaned up automatically.
|
|
257
|
+
|
|
258
|
+
Args:
|
|
259
|
+
subject: NATS subject to read from
|
|
260
|
+
wait: Timeout for initial document read (seconds)
|
|
261
|
+
**kwargs: Additional MsgReader options
|
|
262
|
+
|
|
263
|
+
Returns:
|
|
264
|
+
LiveDocument: The live document (dict-like, auto-updates)
|
|
265
|
+
|
|
266
|
+
Example:
|
|
267
|
+
# Simple usage - similar to single_read()
|
|
268
|
+
config = await get_live_document('app.config')
|
|
269
|
+
host = config.database.host # Auto-updates when republished!
|
|
270
|
+
|
|
271
|
+
# Access the reader if needed (for explicit close)
|
|
272
|
+
config = await get_live_document('app.config')
|
|
273
|
+
# ... use config ...
|
|
274
|
+
await config._reader.close() # Explicit cleanup if needed
|
|
275
|
+
"""
|
|
276
|
+
reader = get_documentreader(subject, initial_wait=wait, **kwargs)
|
|
277
|
+
await reader.open()
|
|
278
|
+
|
|
279
|
+
# Attach reader to document for lifecycle management
|
|
280
|
+
reader.document._reader = reader
|
|
281
|
+
|
|
282
|
+
return reader.document
|
|
@@ -76,7 +76,6 @@ class MsgReader(MsgDriver):
|
|
|
76
76
|
deliver_policy = 'all',
|
|
77
77
|
opt_start_time = None,
|
|
78
78
|
consumer_cfg=None,
|
|
79
|
-
nowait: bool = False,
|
|
80
79
|
**kwargs) -> None:
|
|
81
80
|
if parent is None:
|
|
82
81
|
parent = Messenger()
|
|
@@ -245,30 +244,32 @@ class MsgReader(MsgDriver):
|
|
|
245
244
|
@async_shield
|
|
246
245
|
async def read_batch(self) -> None:
|
|
247
246
|
if len(self.reader.messages) == 0:
|
|
248
|
-
timeout
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
|
|
247
|
+
# For fetch_available, timeout is network latency budget (not message wait time)
|
|
248
|
+
# Server responds immediately with no_wait=True, we just account for slow networks
|
|
249
|
+
fetch_timeout = 2.0 # Reduced - should be quick with no_wait
|
|
250
|
+
log.debug(self.fmt(f"Pulling {self.reader.batch} messages with timeout {fetch_timeout}s"))
|
|
251
|
+
new_msgs = await self.reader.fetch_available(batch=self.reader.batch, timeout=fetch_timeout)
|
|
252
252
|
log.debug(self.fmt(f"Pulled {len(new_msgs)} messages"))
|
|
253
253
|
|
|
254
|
-
# If no messages were available
|
|
255
|
-
# to wait for at least one message more efficiently
|
|
254
|
+
# If no messages were available (got 404 from server or timeout), decide what to do
|
|
256
255
|
if len(new_msgs) == 0:
|
|
257
256
|
if self.reader.nowait:
|
|
258
|
-
|
|
257
|
+
# Server confirmed no messages - finish iteration
|
|
258
|
+
log.debug(self.fmt(f"No messages available, finishing due to nowait"))
|
|
259
259
|
raise self.EndIterationException('nowait')
|
|
260
260
|
|
|
261
|
-
|
|
261
|
+
# Non-nowait mode: wait for NEW messages to arrive
|
|
262
|
+
blocking_timeout = 100.0
|
|
263
|
+
log.debug(self.fmt(f"No messages currently available, waiting for new messages (timeout {blocking_timeout}s)"))
|
|
262
264
|
|
|
263
|
-
# Use the regular fetch operation
|
|
265
|
+
# Use the regular fetch operation which waits for messages to arrive
|
|
264
266
|
try:
|
|
265
|
-
new_msgs = await self.reader.pull_subscription.fetch(1, timeout=
|
|
266
|
-
log.debug(self.fmt(f"Received {len(new_msgs)} message
|
|
267
|
+
new_msgs = await self.reader.pull_subscription.fetch(1, timeout=blocking_timeout)
|
|
268
|
+
log.debug(self.fmt(f"Received {len(new_msgs)} new message(s)"))
|
|
267
269
|
except asyncio.TimeoutError:
|
|
268
|
-
log.debug(self.fmt(f"No
|
|
270
|
+
log.debug(self.fmt(f"No new messages arrived within {blocking_timeout}s"))
|
|
269
271
|
except Exception as e:
|
|
270
272
|
log.warning(self.fmt(f"Error waiting for messages: {e}"))
|
|
271
|
-
# fi : no messages available immediately
|
|
272
273
|
|
|
273
274
|
self.reader.messages.extend(new_msgs)
|
|
274
275
|
|
|
@@ -346,21 +347,30 @@ class MsgReader(MsgDriver):
|
|
|
346
347
|
# end while
|
|
347
348
|
|
|
348
349
|
|
|
349
|
-
async def fetch_available(self, batch=10, timeout
|
|
350
|
-
"""[NATS fix] Fetch only immediately available messages without blocking
|
|
350
|
+
async def fetch_available(self, batch=10, timeout=2.0):
|
|
351
|
+
"""[NATS fix] Fetch only immediately available messages without blocking for new messages
|
|
351
352
|
|
|
352
|
-
|
|
353
|
-
|
|
353
|
+
Uses no_wait=True protocol:
|
|
354
|
+
- Server sends available messages immediately
|
|
355
|
+
- Server sends 404 status if no messages exist
|
|
356
|
+
- We wait for server response (network latency) but not for new messages to arrive
|
|
357
|
+
|
|
358
|
+
Args:
|
|
359
|
+
batch: Maximum number of messages to fetch
|
|
360
|
+
timeout: Network latency budget (default 2s for local/fast networks)
|
|
361
|
+
NOT a "wait for messages" timeout - server won't wait
|
|
362
|
+
|
|
363
|
+
Returns:
|
|
364
|
+
List of messages that were immediately available on server
|
|
354
365
|
"""
|
|
355
366
|
import json
|
|
356
367
|
|
|
357
368
|
pull_subscription = self.pull_subscription
|
|
358
|
-
# Get from internal queue first
|
|
359
369
|
msgs = []
|
|
360
370
|
needed = batch
|
|
361
371
|
queue = pull_subscription._sub._pending_queue
|
|
362
372
|
|
|
363
|
-
# First
|
|
373
|
+
# First, drain messages already in local queue
|
|
364
374
|
while not queue.empty() and needed > 0:
|
|
365
375
|
try:
|
|
366
376
|
msg = queue.get_nowait()
|
|
@@ -369,14 +379,15 @@ class MsgReader(MsgDriver):
|
|
|
369
379
|
if not status: # Skip status messages
|
|
370
380
|
msgs.append(msg)
|
|
371
381
|
needed -= 1
|
|
382
|
+
queue.task_done()
|
|
372
383
|
except Exception:
|
|
373
384
|
pass
|
|
374
385
|
|
|
375
|
-
# If we
|
|
386
|
+
# If we got enough from queue, return immediately
|
|
376
387
|
if needed == 0:
|
|
377
388
|
return msgs
|
|
378
389
|
|
|
379
|
-
#
|
|
390
|
+
# Send pull request: no_wait=True means server responds immediately
|
|
380
391
|
next_req = {"batch": needed, "no_wait": True}
|
|
381
392
|
await pull_subscription._nc.publish(
|
|
382
393
|
pull_subscription._nms,
|
|
@@ -384,26 +395,74 @@ class MsgReader(MsgDriver):
|
|
|
384
395
|
pull_subscription._deliver,
|
|
385
396
|
)
|
|
386
397
|
|
|
398
|
+
# Wait for server response (messages and/or 404 status)
|
|
399
|
+
# The server will respond quickly because no_wait=True
|
|
400
|
+
# We just need to account for network latency
|
|
387
401
|
start_time = time.monotonic()
|
|
402
|
+
got_404_status = False
|
|
403
|
+
got_any_message = False
|
|
388
404
|
|
|
389
|
-
|
|
390
|
-
while needed > 0:
|
|
405
|
+
while not got_404_status:
|
|
391
406
|
deadline = timeout - (time.monotonic() - start_time)
|
|
392
407
|
if deadline <= 0:
|
|
408
|
+
# Timeout - if we got some messages, that's OK (server might not send 404 after exact batch)
|
|
409
|
+
# If we got nothing, this is a network problem
|
|
410
|
+
if not got_any_message and len(msgs) == 0:
|
|
411
|
+
log.warning(f"{self} Timeout after {timeout}s waiting for server response (network issue?)")
|
|
412
|
+
else:
|
|
413
|
+
log.debug(f"{self} Timeout after getting {len(msgs)} messages (no 404 received)")
|
|
393
414
|
break
|
|
394
415
|
|
|
395
416
|
try:
|
|
396
|
-
|
|
417
|
+
# Wait for server to respond (messages or 404)
|
|
418
|
+
msg = await asyncio.wait_for(queue.get(), timeout=deadline)
|
|
419
|
+
pull_subscription._sub._pending_size -= len(msg.data)
|
|
420
|
+
queue.task_done()
|
|
421
|
+
|
|
397
422
|
status = JetStreamContext.is_status_msg(msg)
|
|
398
|
-
|
|
423
|
+
|
|
424
|
+
if status == "404":
|
|
425
|
+
# Server says: no (more) messages available
|
|
426
|
+
# This is the definitive "done" signal
|
|
427
|
+
log.debug(f"{self} Server confirmed no more messages (404 status)")
|
|
428
|
+
got_404_status = True
|
|
429
|
+
break
|
|
430
|
+
elif status == "408":
|
|
431
|
+
# Request Timeout - server gave us what it had but couldn't fill batch
|
|
432
|
+
# This is effectively "no more messages available right now"
|
|
433
|
+
log.debug(f"{self} Server returned partial batch (408 status - request timeout)")
|
|
434
|
+
got_404_status = True # Treat same as 404 - no more messages available
|
|
435
|
+
break
|
|
436
|
+
elif status:
|
|
437
|
+
# Other status message (shouldn't happen with pull, but handle it)
|
|
438
|
+
log.debug(f"{self} Received status: {status}")
|
|
439
|
+
continue
|
|
440
|
+
else:
|
|
441
|
+
# Real message
|
|
399
442
|
msgs.append(msg)
|
|
443
|
+
got_any_message = True
|
|
400
444
|
needed -= 1
|
|
401
|
-
|
|
402
|
-
|
|
445
|
+
|
|
446
|
+
# If we got all requested messages, give server very short time to send 404
|
|
447
|
+
# but don't wait long - likely there are more messages
|
|
448
|
+
if needed == 0:
|
|
449
|
+
# Very short grace period for 404 status (100ms)
|
|
450
|
+
# If 404 doesn't arrive, we'll just fetch again on next iteration
|
|
451
|
+
remaining_time = timeout - (time.monotonic() - start_time)
|
|
452
|
+
if remaining_time > 0.1:
|
|
453
|
+
deadline = 0.1
|
|
454
|
+
timeout = time.monotonic() - start_time + 0.1 # Update timeout to exit on next deadline check
|
|
455
|
+
|
|
403
456
|
except asyncio.TimeoutError:
|
|
457
|
+
# Timeout after getting messages is OK - server might not send 404 if we got exact batch
|
|
458
|
+
if got_any_message or len(msgs) > 0:
|
|
459
|
+
log.debug(f"{self} Got {len(msgs)} messages, no 404 status (normal for exact batch)")
|
|
460
|
+
else:
|
|
461
|
+
# No messages at all - this could be empty subject or network problem
|
|
462
|
+
log.debug(f"{self} No messages or 404 within {timeout}s")
|
|
404
463
|
break
|
|
405
|
-
except
|
|
406
|
-
log.
|
|
464
|
+
except Exception as e:
|
|
465
|
+
log.warning(f"{self} Error in fetch_available: {e}")
|
|
407
466
|
break
|
|
408
467
|
|
|
409
468
|
return msgs
|
|
@@ -540,6 +599,13 @@ class MsgReader(MsgDriver):
|
|
|
540
599
|
|
|
541
600
|
async def _create_consumer_cfg(self) -> ConsumerConfig:
|
|
542
601
|
cfg = self.consumer_cfg.copy()
|
|
602
|
+
|
|
603
|
+
# Remove MsgReader-specific parameters that are not part of ConsumerConfig
|
|
604
|
+
# These are handled by MsgReader itself, not passed to NATS
|
|
605
|
+
reader_params = ['nowait', 'error_behavior', 'on_missed_messages']
|
|
606
|
+
for param in reader_params:
|
|
607
|
+
cfg.pop(param, None)
|
|
608
|
+
|
|
543
609
|
# Convert the delivery policy from a string to the appropriate DeliverPolicy enum
|
|
544
610
|
if self.deliver_policy is not None:
|
|
545
611
|
cfg['deliver_policy'] = DeliverPolicy(self.deliver_policy)
|
|
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
|