serverish 2.1.0__tar.gz → 2.2.1__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 (64) hide show
  1. {serverish-2.1.0 → serverish-2.2.1}/PKG-INFO +2 -2
  2. {serverish-2.1.0 → serverish-2.2.1}/pyproject.toml +5 -5
  3. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/exceptions.py +31 -8
  4. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/messenger.py +8 -1
  5. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_publisher.py +49 -2
  6. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_reader.py +83 -63
  7. {serverish-2.1.0 → serverish-2.2.1}/LICENSE +0 -0
  8. {serverish-2.1.0 → serverish-2.2.1}/README.md +0 -0
  9. {serverish-2.1.0 → serverish-2.2.1}/serverish/__init__.py +0 -0
  10. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/__init__.py +0 -0
  11. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/asyncio_util_functions.py +0 -0
  12. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/collector.py +0 -0
  13. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/datetime.py +0 -0
  14. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/fifoset.py +0 -0
  15. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/hasstatuses.py +0 -0
  16. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/idmanger.py +0 -0
  17. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/iterators.py +0 -0
  18. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/manageable.py +0 -0
  19. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/singleton.py +0 -0
  20. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/status.py +0 -0
  21. {serverish-2.1.0 → serverish-2.2.1}/serverish/base/task_manager.py +0 -0
  22. {serverish-2.1.0 → serverish-2.2.1}/serverish/connection/__init__.py +0 -0
  23. {serverish-2.1.0 → serverish-2.2.1}/serverish/connection/connection.py +0 -0
  24. {serverish-2.1.0 → serverish-2.2.1}/serverish/connection/connection_jets.py +0 -0
  25. {serverish-2.1.0 → serverish-2.2.1}/serverish/connection/connection_nats.py +0 -0
  26. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/__init__.py +0 -0
  27. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/live_document.py +0 -0
  28. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/logging.py +0 -0
  29. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_callback_sub.py +0 -0
  30. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_cmd_pub.py +0 -0
  31. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_cmd_sub.py +0 -0
  32. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_core_pub.py +0 -0
  33. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_core_read.py +0 -0
  34. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_core_sub.py +0 -0
  35. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_document_read.py +0 -0
  36. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_journal_pub.py +0 -0
  37. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_journal_read.py +0 -0
  38. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_kv.py +0 -0
  39. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_kv_read.py +0 -0
  40. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_kv_store.py +0 -0
  41. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_kv_sub.py +0 -0
  42. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_progress_pub.py +0 -0
  43. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_progress_read.py +0 -0
  44. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_rpc_req.py +0 -0
  45. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_rpc_resp.py +0 -0
  46. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_single_pub.py +0 -0
  47. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msg_single_read.py +0 -0
  48. {serverish-2.1.0 → serverish-2.2.1}/serverish/messenger/msgvalidator.py +0 -0
  49. {serverish-2.1.0 → serverish-2.2.1}/serverish/monitoring/__init__.py +0 -0
  50. {serverish-2.1.0 → serverish-2.2.1}/serverish/monitoring/bridge.py +0 -0
  51. {serverish-2.1.0 → serverish-2.2.1}/serverish/monitoring/factory.py +0 -0
  52. {serverish-2.1.0 → serverish-2.2.1}/serverish/monitoring/monitored.py +0 -0
  53. {serverish-2.1.0 → serverish-2.2.1}/serverish/monitoring/monitored_nats.py +0 -0
  54. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/config.schema.json +0 -0
  55. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/default.schema.json +0 -0
  56. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/include/timestamp.schema.json +0 -0
  57. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/journal.schema.json +0 -0
  58. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/meta.schema.json +0 -0
  59. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/program_current_state.schema.json +0 -0
  60. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/program_state.schema.json +0 -0
  61. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/progress.schema.json +0 -0
  62. {serverish-2.1.0 → serverish-2.2.1}/serverish/schema/telemetry.schema.json +0 -0
  63. {serverish-2.1.0 → serverish-2.2.1}/serverish/services/TODO.txt +0 -0
  64. {serverish-2.1.0 → serverish-2.2.1}/serverish/slog.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: serverish
3
- Version: 2.1.0
3
+ Version: 2.2.1
4
4
  Summary: helpers for server alike projects
5
5
  License-File: LICENSE
6
6
  Author: Mikołaj Kałuszyński
@@ -13,7 +13,7 @@ Classifier: Programming Language :: Python :: 3.13
13
13
  Classifier: Programming Language :: Python :: 3.14
14
14
  Provides-Extra: dns
15
15
  Provides-Extra: messenger
16
- Requires-Dist: aiodns (>=3.2.0,<4.0.0) ; extra == "dns"
16
+ Requires-Dist: aiodns (>=3.2,<5) ; extra == "dns"
17
17
  Requires-Dist: jsonschema (>=4.20.0,<5.0.0)
18
18
  Requires-Dist: nats-py (>=2.7.2,<3.0.0) ; extra == "messenger"
19
19
  Requires-Dist: param (>=2.1.0,<3.0.0)
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "serverish"
3
- version = "2.1.0"
3
+ version = "2.2.1"
4
4
  description = "helpers for server alike projects"
5
5
  authors = ["Mikołaj Kałuszyński", "MMME team"]
6
6
  readme = "README.md"
@@ -9,7 +9,7 @@ include = ["serverish/schema/*"]
9
9
 
10
10
  [tool.poetry.dependencies]
11
11
  python = "^3.10"
12
- aiodns = {version="^3.2.0", optional=true}
12
+ aiodns = {version=">=3.2,<5", optional=true}
13
13
  param = "^2.1.0"
14
14
  nats-py = { version = "^2.7.2", optional= true}
15
15
  jsonschema = "^4.20.0"
@@ -20,12 +20,12 @@ dns = ["aiodns"]
20
20
 
21
21
 
22
22
  [tool.poetry.group.dev.dependencies]
23
- pytest = ">=8.2,<9"
24
- pytest-asyncio = ">=0.24,<1"
23
+ pytest = ">=9.1,<10"
24
+ pytest-asyncio = "^1.4"
25
25
  nats-py = "^2.6.0"
26
26
  jupyter = "*"
27
27
  rich = "^13.5.2"
28
- aiodns = "^3.1.1"
28
+ aiodns = ">=3.1.1,<5"
29
29
  testcontainers = {extras = ["nats"], version = "^4.14.2"}
30
30
  pytest-timeout = "^2.4.0"
31
31
 
@@ -1,13 +1,24 @@
1
- class MessengerCannotConnect(Exception):
1
+ class ServerishError(Exception):
2
+ """Common base for all serverish exceptions
3
+
4
+ Catch this to handle any error originating from the serverish library,
5
+ regardless of the subsystem (messenger, connection, KV). Concrete
6
+ exceptions additionally inherit fitting standard bases (ValueError,
7
+ TimeoutError, KeyError...), so idiomatic generic handlers keep working.
8
+ """
9
+ pass
10
+
11
+
12
+ class MessengerCannotConnect(ServerishError):
2
13
  pass
3
14
 
4
- class MessengerNotConnected(Exception):
15
+ class MessengerNotConnected(ServerishError):
5
16
  pass
6
17
 
7
- class MessengerReaderStopped(Exception):
18
+ class MessengerReaderStopped(ServerishError):
8
19
  pass
9
20
 
10
- class MessengerReaderConfigError(ValueError):
21
+ class MessengerReaderConfigError(ServerishError, ValueError):
11
22
  """Raised when a MsgReader is misconfigured (e.g. missing required start marker).
12
23
 
13
24
  This is a fatal error — the same configuration will never succeed against
@@ -16,10 +27,10 @@ class MessengerReaderConfigError(ValueError):
16
27
  """
17
28
  pass
18
29
 
19
- class MessengerReaderAlreadyOpen(RuntimeError):
30
+ class MessengerReaderAlreadyOpen(ServerishError, RuntimeError):
20
31
  pass
21
32
 
22
- class MessengerRequestNoResponse(Exception):
33
+ class MessengerRequestNoResponse(ServerishError):
23
34
  pass
24
35
 
25
36
  class MessengerRequestNoResponders(MessengerRequestNoResponse):
@@ -41,7 +52,19 @@ class MessengerRequestNoResultYet(MessengerRequestNoResponse):
41
52
  pass
42
53
 
43
54
 
44
- class MessengerKvError(Exception):
55
+ class MessengerPublishAckTimeout(ServerishError, TimeoutError):
56
+ """Raised when JetStream did not confirm a publish within the ack timeout.
57
+
58
+ The message MAY have been delivered and stored — only the acknowledgement
59
+ did not arrive (or was not processed) in time. A starved client event loop
60
+ is the most common cause, not an actual delivery failure. Publishes carry
61
+ a ``Nats-Msg-Id`` header, so JetStream deduplicates retries of the same
62
+ message within the stream's duplicate window.
63
+ """
64
+ pass
65
+
66
+
67
+ class MessengerKvError(ServerishError):
45
68
  pass
46
69
 
47
70
 
@@ -61,4 +84,4 @@ class MessengerKvMalformed(MessengerKvError):
61
84
  An entry written by a non-serverish client fails loudly instead of being
62
85
  silently returned as empty.
63
86
  """
64
- pass
87
+ pass
@@ -24,7 +24,7 @@ from nats.aio.msg import Msg
24
24
  from serverish.base import dt_utcnow_array, dt_from_array, Task, create_task, dt_ensure_array
25
25
  from serverish.base.collector import Collector
26
26
  from serverish.connection.connection_jets import ConnectionJetStream
27
- from serverish.base.idmanger import gen_id
27
+ from serverish.base.idmanger import gen_id, gen_uid
28
28
  from serverish.base.manageable import Manageable
29
29
  from serverish.messenger.msgvalidator import MsgValidator
30
30
  from serverish.base.singleton import Singleton
@@ -51,6 +51,13 @@ class Messenger(Singleton):
51
51
  def __init__(self, name: str = None, parent: Collector = None, **kwargs) -> None:
52
52
  self.validator = MsgValidator()
53
53
  self.opener_task: Task | None = None
54
+ # Random per-process token, part of the Nats-Msg-Id dedup header.
55
+ # meta.id (gen_id) is only process-locally unique - two processes (or one
56
+ # process restarted within the stream's duplicate window) both emit
57
+ # 'msg-1', 'msg-2', ..., and JetStream would silently drop the later
58
+ # publishes as duplicates. The instance prefix makes the dedup identity
59
+ # global while retries still reuse the exact same header.
60
+ self.instance_id: str = gen_uid('mi', 12)
54
61
  super().__init__(name, parent, **kwargs)
55
62
 
56
63
  @property
@@ -7,7 +7,7 @@ import nats.errors
7
7
  import nats.js
8
8
  import param
9
9
 
10
- from serverish.base import MessengerNotConnected
10
+ from serverish.base import MessengerNotConnected, MessengerPublishAckTimeout
11
11
  from serverish.messenger import Messenger
12
12
  from serverish.messenger.messenger import MsgDriver, log
13
13
 
@@ -18,8 +18,19 @@ class MsgPublisher(MsgDriver):
18
18
  Use this class if you want to publish data to a messenger subject.
19
19
  Check for specialist publishers for common use cases.
20
20
 
21
+ Every publish carries a ``Nats-Msg-Id`` header equal to the message
22
+ ``meta.id``, so JetStream deduplicates repeated deliveries of the same
23
+ message within the stream's duplicate window. This makes retrying after
24
+ an ack timeout safe: if the original publish did reach the stream (a
25
+ common case when the client event loop is starved and the ack is simply
26
+ processed too late), the retry is discarded by the server.
27
+
21
28
  Parameters:
22
29
  raise_on_publish_error (bool): Raise on publish error, default `True` re-raises underlying exceptions
30
+ ack_timeout_retries (int): How many times to retry a publish for which no JetStream
31
+ ack arrived in time (default 2). Retries are deduplicated server-side via
32
+ ``Nats-Msg-Id``. When retries are exhausted, `MessengerPublishAckTimeout` is
33
+ raised (subject to `raise_on_publish_error`).
23
34
 
24
35
  Health monitoring:
25
36
  The publisher tracks publish statistics accessible via `health_status` property:
@@ -30,6 +41,9 @@ class MsgPublisher(MsgDriver):
30
41
  """
31
42
 
32
43
  raise_on_publish_error = param.Boolean(default=True, doc="Raise on publish error")
44
+ ack_timeout_retries = param.Integer(default=2, bounds=(0, None),
45
+ doc="Retries when JetStream ack does not arrive in time; "
46
+ "safe thanks to Nats-Msg-Id deduplication")
33
47
 
34
48
  def __init__(self, **kwargs) -> None:
35
49
  # Health monitoring fields
@@ -60,8 +74,14 @@ class MsgPublisher(MsgDriver):
60
74
  log.error(f"Message {msg['meta']['id']} validation error: {e}")
61
75
  raise e
62
76
  self.messenger.log_msg_trace(msg['data'], msg['meta'], f"PUB to {self.subject}")
77
+ headers = dict(kwargs.pop('headers', None) or {})
78
+ # Server-side dedup id: makes ack-timeout retries safe. Must be globally
79
+ # unique, hence the per-process instance prefix - meta.id alone is only
80
+ # process-locally unique and would collide across processes/restarts,
81
+ # silently dropping messages within the stream's duplicate window.
82
+ headers.setdefault('Nats-Msg-Id', f"{self.messenger.instance_id}:{msg['meta']['id']}")
63
83
  try:
64
- await self.connection.js.publish(self.subject, bdata, **kwargs)
84
+ await self._publish_with_ack_retries(bdata, headers, msg['meta']['id'], **kwargs)
65
85
  # Track successful publish
66
86
  self._publish_count += 1
67
87
  self._last_publish_time = time.monotonic()
@@ -90,6 +110,33 @@ class MsgPublisher(MsgDriver):
90
110
  msg['meta']['status'] = str(e)
91
111
  return msg
92
112
 
113
+ async def _publish_with_ack_retries(self, bdata: bytes, headers: dict, msg_id: str, **kwargs) -> None:
114
+ """Publishes to JetStream, retrying when the ack does not arrive in time
115
+
116
+ A missing ack does not mean the message was not delivered - it may
117
+ well be stored while the ack was processed too late (e.g. starved
118
+ client event loop). Retries are deduplicated server-side via the
119
+ Nats-Msg-Id header, so this never produces duplicates.
120
+
121
+ Raises:
122
+ MessengerPublishAckTimeout: no ack after all retries
123
+ """
124
+ for attempt in range(self.ack_timeout_retries + 1):
125
+ try:
126
+ await self.connection.js.publish(self.subject, bdata, headers=headers, **kwargs)
127
+ return
128
+ except nats.errors.TimeoutError as e:
129
+ if attempt < self.ack_timeout_retries:
130
+ log.warning(f"No JetStream ack for message {msg_id} on '{self.subject}' "
131
+ f"(attempt {attempt + 1}/{self.ack_timeout_retries + 1}), retrying - "
132
+ f"deduplicated by Nats-Msg-Id")
133
+ continue
134
+ raise MessengerPublishAckTimeout(
135
+ f"No JetStream ack for message {msg_id} on '{self.subject}' "
136
+ f"after {self.ack_timeout_retries + 1} attempts. The message MAY have been "
137
+ f"delivered - a missing ack often means a starved client event loop, "
138
+ f"not a delivery failure") from e
139
+
93
140
  @property
94
141
  def health_status(self) -> dict:
95
142
  """Returns current health status of the publisher for monitoring
@@ -86,6 +86,7 @@ class MsgReader(MsgDriver):
86
86
  }
87
87
  if consumer_cfg is not None:
88
88
  consumer_cfg_defaults.update(consumer_cfg)
89
+ # pull request size == read-ahead buffer bound; becomes BatchPolicy (docs/design/POLICIES.md)
89
90
  self.batch = 100
90
91
  self.messages = deque()
91
92
  self.pull_subscription: JetStreamContext.PullSubscription | None = None
@@ -435,12 +436,16 @@ class MsgReader(MsgDriver):
435
436
  Uses no_wait=True protocol:
436
437
  - Server sends available messages immediately
437
438
  - Server sends 404 status if no messages exist
438
- - We wait for server response (network latency) but not for new messages to arrive
439
+
440
+ End-of-data is detected deterministically from the `num_pending`
441
+ counter the server stamps on every delivered message - the call
442
+ returns as soon as the server confirms there is nothing more (or the
443
+ batch is full), with no fixed waiting anywhere on the happy path.
439
444
 
440
445
  Args:
441
446
  batch: Maximum number of messages to fetch
442
- timeout: Network latency budget (default 2s for local/fast networks)
443
- NOT a "wait for messages" timeout - server won't wait
447
+ timeout: Network-failure backstop (default 2s). Never consumed on
448
+ the happy path - NOT a "wait for messages" timeout
444
449
 
445
450
  Returns:
446
451
  List of messages that were immediately available on server
@@ -477,78 +482,93 @@ class MsgReader(MsgDriver):
477
482
  pull_subscription._deliver,
478
483
  )
479
484
 
480
- # Wait for server response (messages and/or 404 status)
481
- # The server will respond quickly because no_wait=True
482
- # We just need to account for network latency
485
+ # Wait for server response (messages and/or closing status).
486
+ #
487
+ # Deterministic end-of-data: every JetStream delivery is stamped with
488
+ # `num_pending` — the server-side count of messages still remaining
489
+ # for this consumer *after* that message (correct also for filtered
490
+ # and last_per_subject consumers). The loop therefore ends without
491
+ # any arbitrary grace period:
492
+ # - a message arrives with num_pending == 0 → nothing left, return,
493
+ # - batch filled while num_pending > 0 → return, the caller pulls again,
494
+ # - 404 (empty subject) / 408 (partial-batch closure) → server-confirmed end.
495
+ # `timeout` is a network-failure backstop only; no happy path consumes
496
+ # it, so the call is as fast as the server and the network allow.
483
497
  start_time = time.monotonic()
484
- got_404_status = False
485
- got_any_message = False
486
-
487
- while not got_404_status:
488
- deadline = timeout - (time.monotonic() - start_time)
489
- if deadline <= 0:
490
- # Timeout - if we got some messages, that's OK (server might not send 404 after exact batch)
491
- # If we got nothing, this is a network problem
492
- if not got_any_message and len(msgs) == 0:
493
- log.warning(f"{self} Timeout after {timeout}s waiting for server response (network issue?)")
494
- else:
495
- log.debug(f"{self} Timeout after getting {len(msgs)} messages (no 404 received)")
496
- break
497
-
498
- try:
499
- # Wait for server to respond (messages or 404)
500
- msg = await asyncio.wait_for(queue.get(), timeout=deadline)
501
- pull_subscription._sub._pending_size -= len(msg.data)
502
- queue.task_done()
503
498
 
504
- status = JetStreamContext.is_status_msg(msg)
505
-
506
- if status == "404":
507
- # Server says: no (more) messages available
508
- # This is the definitive "done" signal
509
- log.debug(f"{self} Server confirmed no more messages (404 status)")
510
- got_404_status = True
511
- break
512
- elif status == "408":
513
- # Request Timeout - server gave us what it had but couldn't fill batch
514
- # This is effectively "no more messages available right now"
515
- log.debug(f"{self} Server returned partial batch (408 status - request timeout)")
516
- got_404_status = True # Treat same as 404 - no more messages available
517
- break
518
- elif status:
519
- # Other status message (shouldn't happen with pull, but handle it)
520
- log.debug(f"{self} Received status: {status}")
521
- continue
522
- else:
523
- # Real message
524
- msgs.append(msg)
525
- got_any_message = True
526
- needed -= 1
499
+ def log_backstop():
500
+ if msgs:
501
+ log.warning(f"{self} No end-of-data signal within {timeout}s backstop, "
502
+ f"returning {len(msgs)} messages (no num_pending metadata? network issue?)")
503
+ else:
504
+ log.warning(f"{self} Timeout after {timeout}s waiting for server response (network issue?)")
527
505
 
528
- # If we got all requested messages, give server very short time to send 404
529
- # but don't wait long - likely there are more messages
530
- if needed == 0:
531
- # Very short grace period for 404 status (100ms)
532
- # If 404 doesn't arrive, we'll just fetch again on next iteration
533
- remaining_time = timeout - (time.monotonic() - start_time)
534
- if remaining_time > 0.1:
535
- deadline = 0.1
536
- timeout = time.monotonic() - start_time + 0.1 # Update timeout to exit on next deadline check
506
+ while True:
507
+ remaining = timeout - (time.monotonic() - start_time)
508
+ if remaining <= 0:
509
+ log_backstop()
510
+ break
537
511
 
512
+ try:
513
+ msg = await asyncio.wait_for(queue.get(), timeout=remaining)
538
514
  except asyncio.TimeoutError:
539
- # Timeout after getting messages is OK - server might not send 404 if we got exact batch
540
- if got_any_message or len(msgs) > 0:
541
- log.debug(f"{self} Got {len(msgs)} messages, no 404 status (normal for exact batch)")
542
- else:
543
- # No messages at all - this could be empty subject or network problem
544
- log.debug(f"{self} No messages or 404 within {timeout}s")
515
+ log_backstop()
545
516
  break
546
517
  except Exception as e:
547
518
  log.warning(f"{self} Error in fetch_available: {e}")
548
519
  break
520
+ pull_subscription._sub._pending_size -= len(msg.data)
521
+ queue.task_done()
522
+
523
+ status = JetStreamContext.is_status_msg(msg)
524
+ if status == "404":
525
+ # Server says: no messages available at all
526
+ log.debug(f"{self} Server confirmed no messages (404 status)")
527
+ break
528
+ elif status == "408":
529
+ # Request Timeout - server closed the request after a partial batch
530
+ log.debug(f"{self} Server closed partial batch (408 status)")
531
+ break
532
+ elif status:
533
+ # Other status message (shouldn't happen with pull, but handle it)
534
+ log.debug(f"{self} Received status: {status}")
535
+ continue
536
+
537
+ # Real message
538
+ msgs.append(msg)
539
+ needed -= 1
540
+
541
+ num_pending = self._num_pending(msg)
542
+ if num_pending == 0:
543
+ # Server-confirmed end of data - no reason to wait for the
544
+ # closing status (which may trail by a network round trip,
545
+ # or never come for an exact-batch response)
546
+ log.debug(f"{self} Server reports num_pending=0, returning {len(msgs)} messages")
547
+ break
548
+ if needed == 0:
549
+ # Batch full and the server has more - return immediately,
550
+ # the caller decides whether to pull again
551
+ log.debug(f"{self} Batch of {len(msgs)} filled, "
552
+ f"server reports {num_pending} message(s) still pending")
553
+ break
554
+ # num_pending unknown (no JS metadata): keep reading until a
555
+ # closing status arrives or the backstop hits (degraded mode)
549
556
 
550
557
  return msgs
551
558
 
559
+ @staticmethod
560
+ def _num_pending(msg: Msg) -> int | None:
561
+ """Returns server-side `num_pending` from JetStream message metadata
562
+
563
+ `num_pending` is the number of messages still remaining for this
564
+ consumer after the given message. Returns None if the metadata is
565
+ unavailable (non-JetStream message).
566
+ """
567
+ try:
568
+ return msg.metadata.num_pending
569
+ except Exception:
570
+ return None
571
+
552
572
  async def next_msg(self, timeout: Optional[float] = None) -> Msg:
553
573
  """ [NATS fix] Fetch the next message from the subscription
554
574
  :params timeout: Time in seconds to wait for next message before timing out.
File without changes
File without changes
File without changes