log-foundry 0.10.2.dev88__tar.gz → 0.10.2.dev89__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 (59) hide show
  1. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/_retry.py +26 -2
  4. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/kafka.py +5 -5
  5. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/nats.py +102 -10
  6. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/LICENSE +0 -0
  7. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/README.md +0 -0
  8. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/__init__.py +0 -0
  9. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/_fork.py +0 -0
  11. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/_lifecycle.py +0 -0
  12. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/api.py +0 -0
  13. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/config.py +0 -0
  14. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/console.py +0 -0
  15. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/context.py +0 -0
  16. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/decorator.py +0 -0
  17. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/ids.py +0 -0
  18. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/model.py +0 -0
  19. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/py.typed +0 -0
  20. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/results.py +0 -0
  21. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sanitize.py +0 -0
  22. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/__init__.py +0 -0
  23. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/_batch.py +0 -0
  24. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/_chunk.py +0 -0
  25. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/_socket.py +0 -0
  26. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/_time.py +0 -0
  27. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/base.py +0 -0
  28. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/callback.py +0 -0
  29. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/clickhouse.py +0 -0
  30. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/datadog.py +0 -0
  31. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/elasticsearch.py +0 -0
  32. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/eventhubs.py +0 -0
  33. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/file.py +0 -0
  34. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/filtering.py +0 -0
  35. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/firehose.py +0 -0
  36. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/honeycomb.py +0 -0
  37. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/http.py +0 -0
  38. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/kinesis.py +0 -0
  39. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/logging_sink.py +0 -0
  40. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/logstash.py +0 -0
  41. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/loki.py +0 -0
  42. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/memory.py +0 -0
  43. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/mongodb.py +0 -0
  44. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/multi.py +0 -0
  45. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/newrelic.py +0 -0
  46. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/null.py +0 -0
  47. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/postgres.py +0 -0
  48. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/pubsub.py +0 -0
  49. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/rabbitmq.py +0 -0
  50. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/redis.py +0 -0
  51. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/sentry.py +0 -0
  52. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/sns.py +0 -0
  53. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/splunk.py +0 -0
  54. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/sqlite.py +0 -0
  55. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/sqs.py +0 -0
  56. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/stdout.py +0 -0
  57. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/syslog.py +0 -0
  58. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/sinks/transform.py +0 -0
  59. {log_foundry-0.10.2.dev88 → log_foundry-0.10.2.dev89}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev88
3
+ Version: 0.10.2.dev89
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev88"
23
+ version = "0.10.2.dev89"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -1,4 +1,4 @@
1
- """Shared retry waiting for the sinks (SPEC-027)."""
1
+ """Shared bounding of the sinks' waits and timeouts (SPEC-027, SPEC-047)."""
2
2
 
3
3
  from __future__ import annotations
4
4
 
@@ -9,7 +9,7 @@ from typing import TYPE_CHECKING
9
9
  if TYPE_CHECKING:
10
10
  import threading
11
11
 
12
- __all__ = ["MAX_WAIT", "clamp_server_delay", "wait"]
12
+ __all__ = ["MAX_WAIT", "clamp_server_delay", "usable_timeout", "wait"]
13
13
 
14
14
  MAX_WAIT = 86_400.0
15
15
  """Hard ceiling on any single wait, in seconds — a day.
@@ -87,3 +87,27 @@ def clamp_server_delay(value: float | None, ceiling: float) -> float | None:
87
87
  if not (ceiling > 0):
88
88
  return None
89
89
  return min(value, ceiling)
90
+
91
+
92
+ def usable_timeout(value: float, default: float) -> float:
93
+ """Returns a timeout that actually bounds something, or the caller's default (SPEC-047).
94
+
95
+ ``0`` switches a bounded wait off entirely and ``inf`` restores the unbounded one the bound
96
+ exists to remove, so neither is accepted. The test is ``not (0 < value < inf)`` rather than a
97
+ pair of comparisons, because ``NaN`` compares ``False`` to everything and would otherwise slip
98
+ through — :func:`clamp_server_delay`'s reasoning for ``Retry-After``, applied to a value the
99
+ caller supplies rather than one a destination does.
100
+
101
+ Args:
102
+ value: The caller's timeout.
103
+ default: The fallback, passed in rather than named here because each sink's default is its
104
+ own — ``KafkaSink``'s flush and ``NATSSink``'s publish budget are unrelated numbers, and a
105
+ constant baked in here would silently give one sink the other's.
106
+
107
+ Returns:
108
+ The value if it bounds anything, else the default.
109
+
110
+ Raises:
111
+ None.
112
+ """
113
+ return value if 0 < value < float("inf") else default
@@ -7,6 +7,7 @@ import threading
7
7
  from typing import Any
8
8
 
9
9
  from log_foundry import _diag
10
+ from log_foundry.sinks._retry import usable_timeout
10
11
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
11
12
 
12
13
  __all__ = ["KafkaSink"]
@@ -16,10 +17,9 @@ def _usable_timeout(value: float) -> float:
16
17
  """Returns a flush timeout that is actually a bound, or the default.
17
18
 
18
19
  ``0`` is the value that switched this sink's exit delivery off, and ``inf`` is the unbounded
19
- wait FR-006 exists to remove, so neither is accepted from a caller. The test is
20
- ``not (0 < value < inf)`` rather than a pair of comparisons, because ``NaN`` compares
21
- ``False`` to everything and would otherwise slip through (SPEC-027 FR-001's reasoning for
22
- ``Retry-After``).
20
+ wait FR-006 exists to remove, so neither is accepted from a caller. The rule itself lives in
21
+ :func:`~log_foundry.sinks._retry.usable_timeout`, shared with ``NATSSink``'s publish budget
22
+ since SPEC-047; this wrapper binds it to :data:`DEFAULT_FLUSH_TIMEOUT`.
23
23
 
24
24
  Args:
25
25
  value: The caller's timeout.
@@ -30,7 +30,7 @@ def _usable_timeout(value: float) -> float:
30
30
  Raises:
31
31
  None.
32
32
  """
33
- return value if 0 < value < float("inf") else DEFAULT_FLUSH_TIMEOUT
33
+ return usable_timeout(value, DEFAULT_FLUSH_TIMEOUT)
34
34
 
35
35
  DEFAULT_FLUSH_TIMEOUT = 10.0
36
36
  """Seconds :meth:`KafkaSink.close` waits for the producer to drain (SPEC-038 FR-006).
@@ -5,13 +5,50 @@ from __future__ import annotations
5
5
  import asyncio
6
6
  import json
7
7
  import threading
8
+ import time
8
9
  from typing import Any
9
10
 
10
11
  from log_foundry import _diag
12
+ from log_foundry.sinks._retry import usable_timeout
11
13
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
12
14
 
13
15
  __all__ = ["NATSSink"]
14
16
 
17
+ DEFAULT_PUBLISH_TIMEOUT = 10.0
18
+ """Seconds one whole :meth:`NATSSink.emit` may spend publishing (SPEC-047 FR-001).
19
+
20
+ A budget for the **batch**, not for an event. Under JetStream each publish awaits an ack bounded by
21
+ the driver's own timeout, and applying that per item is ``n x timeout``, which is not a bound --
22
+ measured, five events against a stalled server cost 25.01 s, and ``Worker._final_drain`` hands the
23
+ process's exit backlog over as a single batch (SPEC-038 measured 5,980 events).
24
+
25
+ It is spent inside ``_lifecycle.DEFAULT_SHUTDOWN_TIMEOUT`` (30.0): ``Worker.shutdown`` joins the
26
+ drain thread against that deadline and ``_final_drain``'s single emit runs on that thread, and an
27
+ expired join leaves the sink **open** by SPEC-027 FR-004, so ``close()`` never drains the client's
28
+ outbound buffer.
29
+
30
+ **It does not fit inside that join, and the arithmetic is stated rather than assumed.**
31
+ ``Worker._emit`` retries a failing batch ``max_retries + 1`` times -- four at the default -- and its
32
+ inter-attempt wait returns immediately once the stop event is set, which it is for the whole of the
33
+ exit drain. This deadline deliberately does not consult that event (a shutdown shortens a wait and
34
+ never skips work), so the exit drain's worst case is ``(max_retries + 1) x publish_timeout``. Ten is
35
+ chosen on the size of the improvement rather than on fitting the sequence inside the join: the same
36
+ path is *unbounded* today, 8.3 hours for a 5,980-event backlog at 5 s an event. A caller who needs
37
+ the whole sequence to fit lowers this to 7.0 or below. Recorded as an open item in
38
+ ``architecture.md`` section 12, since closing it means bounding the worker's retry of an
39
+ already-bounded batch -- every sink's question rather than this sink's.
40
+ """
41
+
42
+ DEFAULT_ACK_TIMEOUT = 5.0
43
+ """Ceiling on any one JetStream publish's ack wait, in seconds (SPEC-047 FR-001).
44
+
45
+ A constant of this module's own rather than a value read from the driver: the sink builds its
46
+ context with a bare ``self._client.jetstream()``, so ``JetStreamContext``'s ``timeout`` is its
47
+ constructor default and is not readable back off the object. It mirrors that default, and every
48
+ publish is given ``min(DEFAULT_ACK_TIMEOUT, remaining budget)`` -- so a divergence can only ever
49
+ make the per-publish timeout smaller than the driver would have used, never larger.
50
+ """
51
+
15
52
 
16
53
  class NATSSink:
17
54
  """A :class:`~log_foundry.sinks.base.Sink` that publishes events to a NATS subject.
@@ -22,12 +59,23 @@ class NATSSink:
22
59
  handed off. With JetStream enabled it publishes through JetStream for durable
23
60
  acknowledgement.
24
61
 
25
- **Retry and the worst-case delay** (SPEC-041 FR-004). This sink adds no retry loop and needs
26
- none: a core ``publish()`` writes into the client's outbound buffer and returns without
27
- waiting — measured at 0.00 s for fifty publishes — so it never holds the worker's single
28
- drain thread and there is no backoff for a shutdown to cut short. SPEC-027's guarantee is met
29
- because there is no wait, not because a wait is bounded. Under JetStream ``publish()`` awaits
30
- an ack bounded by the driver's own timeout (5 s by default) and does not retry.
62
+ **Retry and the worst-case delay** (SPEC-041 FR-004, SPEC-047 FR-001). This sink adds no retry
63
+ loop and needs none: a core ``publish()`` writes into the client's outbound buffer and returns
64
+ without waiting — measured at 0.00 s for fifty publishes — so it never holds the worker's
65
+ single drain thread and there is no backoff for a shutdown to cut short. SPEC-027's guarantee
66
+ is met because there is no wait, not because a wait is bounded.
67
+
68
+ > ~~Under JetStream ``publish()`` awaits an ack bounded by the driver's own timeout (5 s by
69
+ > default) and does not retry.~~ **Superseded by SPEC-047 FR-001.** True per *event* and false
70
+ > per *batch*: the awaits are sequential and nothing bounded how many there were, so the cost
71
+ > was ``n x 5 s`` on the drain thread — measured at 25.01 s for five events against a stalled
72
+ > server, and ``Worker._final_drain`` hands the exit backlog over as one batch. One
73
+ > :data:`DEFAULT_PUBLISH_TIMEOUT` now bounds the whole :meth:`emit`.
74
+
75
+ The worst case is therefore ``publish_timeout`` per emit, plus one in-flight ack — and across
76
+ the exit drain ``(max_retries + 1) x publish_timeout``, since ``Worker._emit`` retries a failing
77
+ batch and its inter-attempt wait returns immediately during a shutdown. That exceeds
78
+ ``shutdown()``'s join at the defaults and is recorded in ``architecture.md`` section 12.
31
79
 
32
80
  **A disconnected client is reported, not absorbed** (FR-004 AC-5). That non-blocking publish
33
81
  is exactly what made this sink report success for events that had not left the process: with
@@ -55,6 +103,7 @@ class NATSSink:
55
103
  client: Any = None,
56
104
  jetstream: bool = False,
57
105
  servers: str | None = None,
106
+ publish_timeout: float = DEFAULT_PUBLISH_TIMEOUT,
58
107
  ) -> None:
59
108
  """Binds the sink to a subject and connects if no client was injected.
60
109
 
@@ -63,6 +112,10 @@ class NATSSink:
63
112
  client: A ``nats-py``-shaped client to borrow, or ``None`` to connect one.
64
113
  jetstream: Whether to publish through JetStream.
65
114
  servers: The server URL used when connecting.
115
+ publish_timeout: Seconds one whole :meth:`emit` may spend, floored by
116
+ :func:`~log_foundry.sinks._retry.usable_timeout` as ``KafkaSink`` floors its flush
117
+ (SPEC-047 FR-001). It applies to an injected ``client=`` too: it is this sink's own
118
+ bound over its own loop, not a request made of the driver at connect time.
66
119
 
67
120
  Returns:
68
121
  None.
@@ -73,6 +126,7 @@ class NATSSink:
73
126
  """
74
127
  self._subject = subject
75
128
  self._jetstream = jetstream
129
+ self.publish_timeout = usable_timeout(publish_timeout, DEFAULT_PUBLISH_TIMEOUT)
76
130
  self._loop = asyncio.new_event_loop()
77
131
  self.failed = 0
78
132
  self._counter_lock = threading.Lock()
@@ -179,10 +233,27 @@ class NATSSink:
179
233
  self._loop.close()
180
234
 
181
235
  async def _publish_all(self, batch: list[dict[str, object]]) -> None:
182
- """Publishes each event, isolating a per-event failure.
236
+ """Publishes each event under one deadline for the whole batch (SPEC-047 FR-001).
183
237
 
184
238
  Per-event isolation stays because a partial batch must not be retried wholesale: the
185
- events that published would be delivered twice.
239
+ events that published would be delivered twice. What changes is that the *batch* is
240
+ bounded rather than each event in it — applying the driver's ack timeout per item is
241
+ ``n x timeout``, and ``Worker._final_drain`` hands the exit backlog over as one batch.
242
+
243
+ The two paths take the bound differently because the driver offers different handles. A
244
+ JetStream publish accepts a ``timeout`` and is given ``min(DEFAULT_ACK_TIMEOUT,
245
+ remaining)`` — never the bare remainder, which would hand the first event of a fresh
246
+ budget a longer ack wait than the driver's own default. A core publish accepts none and
247
+ writes into the client's outbound buffer without waiting, so the deadline is checked
248
+ between events, which is all there is to check.
249
+
250
+ Two populations are counted by different rules. An event whose publish **raised** is a
251
+ driver failure and is counted here exactly as before this spec. An event **never
252
+ attempted** because the budget expired is counted only when this returns: a raise sends
253
+ the whole batch back to ``Worker._emit``, which retries it, so booking the remainder there
254
+ would report a loss that has not happened — the rule
255
+ :meth:`~log_foundry.sinks.kafka.KafkaSink.flush` already applies to its own queued
256
+ remainder.
186
257
 
187
258
  Args:
188
259
  batch: The events to publish.
@@ -194,18 +265,39 @@ class NATSSink:
194
265
  SinkDeliveryError: When none of them landed (SPEC-026 FR-001).
195
266
  """
196
267
  target = self._client.jetstream() if self._jetstream else self._client
268
+ deadline = time.monotonic() + self.publish_timeout
197
269
  published = 0
270
+ attempted = 0
198
271
  for event in batch:
272
+ remaining = deadline - time.monotonic()
273
+ if remaining <= 0:
274
+ break
275
+ attempted += 1
199
276
  try:
200
- await target.publish(self._subject, json.dumps(event).encode("utf-8"))
277
+ payload = json.dumps(event).encode("utf-8")
278
+ if self._jetstream:
279
+ await target.publish(
280
+ self._subject, payload, timeout=min(DEFAULT_ACK_TIMEOUT, remaining)
281
+ )
282
+ else:
283
+ await target.publish(self._subject, payload)
201
284
  except Exception as err:
202
285
  with self._counter_lock:
203
286
  self.failed += 1
204
287
  _diag.lost("event", 1, f"NATSSink publish, {type(err).__name__}")
205
288
  else:
206
289
  published += 1
290
+ unattempted = len(batch) - attempted
207
291
  if batch and not published:
208
- raise SinkDeliveryError(f"NATSSink published none of {len(batch)} event(s)")
292
+ raise SinkDeliveryError(
293
+ f"NATSSink published none of {len(batch)} event(s)"
294
+ + (f", {unattempted} not attempted within {self.publish_timeout}s" if unattempted
295
+ else "")
296
+ )
297
+ if unattempted:
298
+ with self._counter_lock:
299
+ self.failed += unattempted
300
+ _diag.lost("event", unattempted, f"NATSSink publish_timeout {self.publish_timeout}s")
209
301
 
210
302
  def flush(self) -> None:
211
303
  """Pushes the client's outbound buffer onto the wire without closing (SPEC-036 FR-002).