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.
Files changed (50) hide show
  1. {serverish-1.4.2 → serverish-1.5.0}/PKG-INFO +3 -1
  2. {serverish-1.4.2 → serverish-1.5.0}/README.md +2 -0
  3. {serverish-1.4.2 → serverish-1.5.0}/pyproject.toml +1 -1
  4. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/__init__.py +2 -0
  5. serverish-1.5.0/serverish/messenger/live_document.py +300 -0
  6. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/messenger.py +48 -2
  7. serverish-1.5.0/serverish/messenger/msg_document_read.py +282 -0
  8. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_reader.py +96 -30
  9. {serverish-1.4.2 → serverish-1.5.0}/LICENSE +0 -0
  10. {serverish-1.4.2 → serverish-1.5.0}/serverish/__init__.py +0 -0
  11. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/__init__.py +0 -0
  12. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/asyncio_util_functions.py +0 -0
  13. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/collector.py +0 -0
  14. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/datetime.py +0 -0
  15. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/exceptions.py +0 -0
  16. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/fifoset.py +0 -0
  17. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/hasstatuses.py +0 -0
  18. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/idmanger.py +0 -0
  19. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/iterators.py +0 -0
  20. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/manageable.py +0 -0
  21. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/singleton.py +0 -0
  22. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/status.py +0 -0
  23. {serverish-1.4.2 → serverish-1.5.0}/serverish/base/task_manager.py +0 -0
  24. {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/__init__.py +0 -0
  25. {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/connection.py +0 -0
  26. {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/connection_jets.py +0 -0
  27. {serverish-1.4.2 → serverish-1.5.0}/serverish/connection/connection_nats.py +0 -0
  28. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/logging.py +0 -0
  29. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_callback_sub.py +0 -0
  30. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_journal_pub.py +0 -0
  31. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_journal_read.py +0 -0
  32. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_progress_pub.py +0 -0
  33. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_progress_read.py +0 -0
  34. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_publisher.py +0 -0
  35. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_rpc_req.py +0 -0
  36. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_rpc_resp.py +0 -0
  37. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_single_pub.py +0 -0
  38. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msg_single_read.py +0 -0
  39. {serverish-1.4.2 → serverish-1.5.0}/serverish/messenger/msgvalidator.py +0 -0
  40. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/config.schema.json +0 -0
  41. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/default.schema.json +0 -0
  42. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/include/timestamp.schema.json +0 -0
  43. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/journal.schema.json +0 -0
  44. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/meta.schema.json +0 -0
  45. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/program_current_state.schema.json +0 -0
  46. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/program_state.schema.json +0 -0
  47. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/progress.schema.json +0 -0
  48. {serverish-1.4.2 → serverish-1.5.0}/serverish/schema/telemetry.schema.json +0 -0
  49. {serverish-1.4.2 → serverish-1.5.0}/serverish/services/TODO.txt +0 -0
  50. {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.4.2
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
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "serverish"
3
- version = "1.4.2"
3
+ version = "1.5.0"
4
4
  description = "helpers for server alike projects"
5
5
  authors = ["Mikołaj Kałuszyński", "MMME team"]
6
6
  readme = "README.md"
@@ -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=kwargs)
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 = 100.0 # min(0.1 + self.n/10.0, 5.0)
249
- # batch = 1 if self.error is not None else 100 # recover slowly
250
- log.debug(self.fmt(f"Pulling {self.reader.batch} messages with timeout {timeout}s"))
251
- new_msgs = await self.reader.fetch_available(batch=self.reader.batch, timeout=timeout)
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 immediately, switch to blocking mode
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
- log.debug(self.fmt(f"No new messages found, finishing due to nowait"))
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
- log.debug(self.fmt(f"No messages available immediately, waiting for at least one message with timeout {timeout:0.1f}s"))
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 from nats (which blocks)
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=timeout)
266
- log.debug(self.fmt(f"Received {len(new_msgs)} message while blocking waiting"))
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 meesge before timeout"))
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 = 0.1):
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
- Path on lack of functionality of JetStreamContext.PullSubscription.fetch
353
- By default uses only very short timeout to account for network latency
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 get messages from the internal queue
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 already have enough messages, return early
386
+ # If we got enough from queue, return immediately
376
387
  if needed == 0:
377
388
  return msgs
378
389
 
379
- # Make one no_wait request to get immediately available messages
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
- # Process the response with a very short timeout
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
- msg = await self.next_msg(timeout=0.0) # nonblocking here
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
- if not status:
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
- elif status == "404": # NO_MESSAGES
402
- break
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 asyncio.QueueEmpty:
406
- log.debug(f"{self} Queue is empty")
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