log-foundry 0.10.2.dev23__tar.gz → 0.10.2.dev25__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 (55) hide show
  1. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/PKG-INFO +28 -10
  2. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/README.md +27 -9
  3. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_socket.py +21 -4
  5. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/base.py +9 -0
  6. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/clickhouse.py +34 -15
  7. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/elasticsearch.py +9 -6
  8. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/eventhubs.py +45 -8
  9. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/file.py +16 -2
  10. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/firehose.py +18 -11
  11. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/http.py +6 -3
  12. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/kafka.py +12 -3
  13. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/kinesis.py +18 -11
  14. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/mongodb.py +49 -15
  15. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/multi.py +6 -4
  16. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/nats.py +27 -9
  17. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/postgres.py +49 -13
  18. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/pubsub.py +29 -7
  19. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/rabbitmq.py +40 -16
  20. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/redis.py +14 -6
  21. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sentry.py +16 -8
  22. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sns.py +13 -7
  23. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sqlite.py +37 -4
  24. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sqs.py +18 -8
  25. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/util.py +4 -1
  26. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/worker.py +19 -4
  27. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/LICENSE +0 -0
  28. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/__init__.py +0 -0
  29. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/_diag.py +0 -0
  30. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/api.py +0 -0
  31. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/config.py +0 -0
  32. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/console.py +0 -0
  33. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/context.py +0 -0
  34. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/decorator.py +0 -0
  35. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/ids.py +0 -0
  36. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/model.py +0 -0
  37. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/py.typed +0 -0
  38. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sanitize.py +0 -0
  39. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/__init__.py +0 -0
  40. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_batch.py +0 -0
  41. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_chunk.py +0 -0
  42. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_retry.py +0 -0
  43. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_time.py +0 -0
  44. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/callback.py +0 -0
  45. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/datadog.py +0 -0
  46. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/filtering.py +0 -0
  47. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/honeycomb.py +0 -0
  48. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/logging_sink.py +0 -0
  49. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/logstash.py +0 -0
  50. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/loki.py +0 -0
  51. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/newrelic.py +0 -0
  52. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/splunk.py +0 -0
  53. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/stdout.py +0 -0
  54. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/syslog.py +0 -0
  55. {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/transform.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev23
3
+ Version: 0.10.2.dev25
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -669,7 +669,16 @@ in `CallbackSink`.
669
669
  #### Writing your own sink
670
670
 
671
671
  `Sink` is two required methods, `emit(batch)` and `close()`, plus two rules about *how* `emit`
672
- fails. They are not stylistic — the library's whole loss-reporting apparatus is built on them:
672
+ fails and one about *when* it is called. They are not stylistic — the library's whole
673
+ loss-reporting apparatus is built on them:
674
+
675
+ - **Tolerate concurrent calls.** `emit` may run on more than one thread at once, and `close` may
676
+ be called while an `emit` is in flight. The worker drains on its own thread, but a level call
677
+ made with no active span emits synchronously on the *caller's* thread — which is any thread of
678
+ your application. If your sink holds mutable transport state (a stream it rebinds, a socket it
679
+ reuses, a connection with transaction scope), guard it with a `threading.Lock` held for the
680
+ whole operation that assumes exclusivity. If it holds none, you need do nothing. The library
681
+ cannot serialize this for you: it does not own the calling thread.
673
682
 
674
683
  - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
675
684
  signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
@@ -685,29 +694,38 @@ never engages, `failed_batches` stays at zero, and `flush()` returns `True` whil
685
694
  lost.
686
695
 
687
696
  Optionally add `losses()` to report what you absorbed. It must never raise and must be safe to call
688
- while `emit` is running (`health()` is a poll):
697
+ while `emit` is running (`health()` is a poll) — which means the counters need their own lock, kept
698
+ separate from the transport one so a poll never waits on an in-flight send:
689
699
 
690
700
  ```python
701
+ import threading
691
702
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
692
703
 
693
704
  class MySink:
694
705
  def __init__(self) -> None:
695
706
  self._dropped = self._failed = 0
707
+ self._lock = threading.Lock() # transport state
708
+ self._counter_lock = threading.Lock() # counters only, never held across I/O
696
709
 
697
710
  def emit(self, batch: list[dict[str, object]]) -> None:
698
711
  delivered = 0
699
- for chunk in self._chunks(batch):
700
- if self._send(chunk): # your own bounded retry
701
- delivered += len(chunk)
702
- else:
703
- self._failed += len(chunk)
712
+ with self._lock: # your connection, socket or stream
713
+ for chunk in self._chunks(batch):
714
+ if self._send(chunk): # your own bounded retry
715
+ delivered += len(chunk)
716
+ else:
717
+ with self._counter_lock: # transport -> counter, never the reverse
718
+ self._failed += len(chunk)
704
719
  if batch and not delivered:
705
720
  raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
706
721
 
707
722
  def losses(self) -> SinkLosses:
708
- return SinkLosses(dropped=self._dropped, failed=self._failed)
723
+ with self._counter_lock: # both fields from one instant
724
+ return SinkLosses(dropped=self._dropped, failed=self._failed)
709
725
 
710
- def close(self) -> None: ...
726
+ def close(self) -> None:
727
+ with self._lock: # never release under an active writer
728
+ ...
711
729
  ```
712
730
 
713
731
  `losses()` is optional and probed by name, so a sink written before it existed keeps working and
@@ -633,7 +633,16 @@ in `CallbackSink`.
633
633
  #### Writing your own sink
634
634
 
635
635
  `Sink` is two required methods, `emit(batch)` and `close()`, plus two rules about *how* `emit`
636
- fails. They are not stylistic — the library's whole loss-reporting apparatus is built on them:
636
+ fails and one about *when* it is called. They are not stylistic — the library's whole
637
+ loss-reporting apparatus is built on them:
638
+
639
+ - **Tolerate concurrent calls.** `emit` may run on more than one thread at once, and `close` may
640
+ be called while an `emit` is in flight. The worker drains on its own thread, but a level call
641
+ made with no active span emits synchronously on the *caller's* thread — which is any thread of
642
+ your application. If your sink holds mutable transport state (a stream it rebinds, a socket it
643
+ reuses, a connection with transaction scope), guard it with a `threading.Lock` held for the
644
+ whole operation that assumes exclusivity. If it holds none, you need do nothing. The library
645
+ cannot serialize this for you: it does not own the calling thread.
637
646
 
638
647
  - **Raise when you delivered none of the batch**, after your own retries are spent. That is the
639
648
  signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
@@ -649,29 +658,38 @@ never engages, `failed_batches` stays at zero, and `flush()` returns `True` whil
649
658
  lost.
650
659
 
651
660
  Optionally add `losses()` to report what you absorbed. It must never raise and must be safe to call
652
- while `emit` is running (`health()` is a poll):
661
+ while `emit` is running (`health()` is a poll) — which means the counters need their own lock, kept
662
+ separate from the transport one so a poll never waits on an in-flight send:
653
663
 
654
664
  ```python
665
+ import threading
655
666
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
656
667
 
657
668
  class MySink:
658
669
  def __init__(self) -> None:
659
670
  self._dropped = self._failed = 0
671
+ self._lock = threading.Lock() # transport state
672
+ self._counter_lock = threading.Lock() # counters only, never held across I/O
660
673
 
661
674
  def emit(self, batch: list[dict[str, object]]) -> None:
662
675
  delivered = 0
663
- for chunk in self._chunks(batch):
664
- if self._send(chunk): # your own bounded retry
665
- delivered += len(chunk)
666
- else:
667
- self._failed += len(chunk)
676
+ with self._lock: # your connection, socket or stream
677
+ for chunk in self._chunks(batch):
678
+ if self._send(chunk): # your own bounded retry
679
+ delivered += len(chunk)
680
+ else:
681
+ with self._counter_lock: # transport -> counter, never the reverse
682
+ self._failed += len(chunk)
668
683
  if batch and not delivered:
669
684
  raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
670
685
 
671
686
  def losses(self) -> SinkLosses:
672
- return SinkLosses(dropped=self._dropped, failed=self._failed)
687
+ with self._counter_lock: # both fields from one instant
688
+ return SinkLosses(dropped=self._dropped, failed=self._failed)
673
689
 
674
- def close(self) -> None: ...
690
+ def close(self) -> None:
691
+ with self._lock: # never release under an active writer
692
+ ...
675
693
  ```
676
694
 
677
695
  `losses()` is optional and probed by name, so a sink written before it existed keeps working and
@@ -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.dev23"
23
+ version = "0.10.2.dev25"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -108,8 +108,10 @@ class SocketTransport:
108
108
  self._max_retries = max(max_retries, 0)
109
109
  self._sock: socket.socket | None = None
110
110
  self.failed = 0
111
+ self._counter_lock = threading.Lock()
111
112
  self.stop_signal: threading.Event | None = None
112
113
  self._lock = threading.Lock()
114
+ self._closed = False
113
115
 
114
116
  def send_all(self, messages: list[bytes]) -> None:
115
117
  """Sends each pre-framed message, reconnecting on error (FR-005, FR-006).
@@ -118,6 +120,11 @@ class SocketTransport:
118
120
  reconnect rebinds, so releasing between messages would let another thread send on a
119
121
  socket this one is about to reset (SPEC-028 FR-002).
120
122
 
123
+ A closed transport refuses rather than reconnecting. ``_socket`` opens a connection
124
+ whenever it holds none and ``close`` only drops the one it has, so without this a single
125
+ ``log_foundry.info()`` after ``shutdown()`` would open a TCP connection nothing will ever
126
+ reap — measured, and the same leak ``RabbitMQSink`` had (SPEC-028).
127
+
121
128
  Args:
122
129
  messages: The exact bytes to put on the wire, one call per message.
123
130
 
@@ -129,14 +136,21 @@ class SocketTransport:
129
136
  this transport propagate a dead destination to the worker instead of reporting
130
137
  success (SPEC-026 FR-001). A partial send does not raise, because the worker's retry
131
138
  would re-send the messages that already landed, and an empty call is a no-op rather
132
- than a total failure.
139
+ than a total failure. Also when the transport is already closed.
133
140
  """
141
+ if not messages:
142
+ return
134
143
  with self._lock:
144
+ if self._closed:
145
+ raise SinkDeliveryError(
146
+ f"SocketTransport delivered none of {len(messages)} message(s): "
147
+ f"the transport is closed"
148
+ )
135
149
  delivered = 0
136
150
  for message in messages:
137
151
  if self._send_one(message):
138
152
  delivered += 1
139
- if messages and delivered == 0:
153
+ if delivered == 0:
140
154
  raise SinkDeliveryError(
141
155
  f"SocketTransport delivered none of {len(messages)} message(s)"
142
156
  )
@@ -153,7 +167,8 @@ class SocketTransport:
153
167
  Raises:
154
168
  None.
155
169
  """
156
- return SinkLosses(dropped=0, failed=self.failed)
170
+ with self._counter_lock:
171
+ return SinkLosses(dropped=0, failed=self.failed)
157
172
 
158
173
  def close(self) -> None:
159
174
  """Closes the held socket, if any (FR-005, FR-012).
@@ -173,6 +188,7 @@ class SocketTransport:
173
188
  None.
174
189
  """
175
190
  with self._lock:
191
+ self._closed = True
176
192
  self._reset()
177
193
 
178
194
  def _send_one(self, message: bytes) -> bool:
@@ -205,7 +221,8 @@ class SocketTransport:
205
221
  if attempt < self._max_retries:
206
222
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
207
223
  continue
208
- self.failed += 1
224
+ with self._counter_lock:
225
+ self.failed += 1
209
226
  _diag.lost(
210
227
  "message",
211
228
  1,
@@ -57,6 +57,15 @@ class Sink(Protocol):
57
57
  ``None`` is the same answer as having no method at all, which is what keeps a third-party
58
58
  sink written against the pre-SPEC-026 interface satisfying this one. :func:`read_losses` is
59
59
  the probe.
60
+
61
+ "Safe to call during an emit" is a concurrency requirement once :meth:`emit` is (SPEC-028
62
+ FR-003). The shipped sinks keep their loss counters under a **dedicated** lock, separate
63
+ from whatever guards their transport: an increment is a read-modify-write that Python does
64
+ not promise is atomic, and the pair read must come from one instant rather than straddling
65
+ another thread's increments. It is deliberately not the transport lock — ``health()`` is
66
+ the call an operator makes when a destination is already hanging, and sharing one lock
67
+ would make that poll wait for an in-flight emit and its retry backoff. Where a sink holds
68
+ both, the order is always transport then counter, never the reverse.
60
69
  """
61
70
 
62
71
  def emit(self, batch: list[dict[str, object]]) -> None:
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._chunk import chunk_list, valid_identifier
@@ -42,6 +40,13 @@ class ClickHouseSink:
42
40
  columns plus the full event as a ``String`` column, inserted in a single call per chunk. The
43
41
  sink is write-only, and the worst-case delay (SPEC-027 FR-005) is ``max_retries``
44
42
  interruptible waits per chunk, 0.7 s at the defaults.
43
+
44
+ The driver requirement satisfied (SPEC-028 FR-002): ``clickhouse-connect`` states that a
45
+ client is **not** thread-safe when using session ids, and that concurrent queries in one
46
+ session raise ``ProgrammingError``. This sink calls ``get_client(dsn=...)``, which takes the
47
+ default auto-generated session, so it is squarely in that case and the lock is required
48
+ rather than merely prudent. One client per thread would be the alternative, and that is the
49
+ connection-pool design FR-002 puts out of scope.
45
50
  """
46
51
 
47
52
  def __init__(
@@ -81,6 +86,8 @@ class ClickHouseSink:
81
86
  self.stop_signal: threading.Event | None = None
82
87
  self.failed = 0
83
88
  self._closed = False
89
+ self._lock = threading.Lock()
90
+ self._counter_lock = threading.Lock()
84
91
  self._owns_client = client is None
85
92
  if client is None:
86
93
  import clickhouse_connect # type: ignore[import-not-found]
@@ -93,6 +100,9 @@ class ClickHouseSink:
93
100
  def losses(self) -> SinkLosses:
94
101
  """Reports rows in a chunk abandoned past the retry bound (SPEC-026 FR-002).
95
102
 
103
+ Reads under the counter lock rather than the emit lock (SPEC-028 FR-003), so a poll
104
+ never waits on an in-flight insert and its backoff.
105
+
96
106
  Args:
97
107
  None.
98
108
 
@@ -102,7 +112,8 @@ class ClickHouseSink:
102
112
  Raises:
103
113
  None.
104
114
  """
105
- return SinkLosses(dropped=0, failed=self.failed)
115
+ with self._counter_lock:
116
+ return SinkLosses(dropped=0, failed=self.failed)
106
117
 
107
118
  def emit(self, batch: list[dict[str, object]]) -> None:
108
119
  """Inserts each chunk as one columnar call, retrying on failure (FR-002).
@@ -122,16 +133,22 @@ class ClickHouseSink:
122
133
  if not batch:
123
134
  return
124
135
  chunks = inserted = 0
125
- for chunk in chunk_list(batch, self._chunk_size):
126
- chunks += 1
127
- inserted += self._insert([self._row(event) for event in chunk])
136
+ with self._lock:
137
+ if self._closed:
138
+ raise SinkDeliveryError(
139
+ f"ClickHouseSink inserted none of {len(batch)} event(s): the sink is closed"
140
+ )
141
+ for chunk in chunk_list(batch, self._chunk_size):
142
+ chunks += 1
143
+ inserted += self._insert([self._row(event) for event in chunk])
128
144
  if chunks and not inserted:
129
145
  raise SinkDeliveryError(f"ClickHouseSink inserted none of {chunks} chunk(s)")
130
146
 
131
147
  def close(self) -> None:
132
148
  """Closes the client only if the sink owns it (FR-005).
133
149
 
134
- Idempotent.
150
+ Idempotent, and takes the emit lock so the client is never closed mid-insert
151
+ (SPEC-028 FR-002).
135
152
 
136
153
  Args:
137
154
  None.
@@ -142,11 +159,12 @@ class ClickHouseSink:
142
159
  Raises:
143
160
  Exception: Whatever the client raises on close.
144
161
  """
145
- if self._closed:
146
- return
147
- if self._owns_client:
148
- self.client.close()
149
- self._closed = True
162
+ with self._lock:
163
+ if self._closed:
164
+ return
165
+ self._closed = True
166
+ if self._owns_client:
167
+ self.client.close()
150
168
 
151
169
  def _row(self, event: dict[str, object]) -> list[object]:
152
170
  """Builds one row: the extracted columns, then the whole event as JSON.
@@ -182,7 +200,8 @@ class ClickHouseSink:
182
200
  if attempt < self.max_retries:
183
201
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
184
202
  continue
185
- self.failed += len(rows)
203
+ with self._counter_lock:
204
+ self.failed += len(rows)
186
205
  _diag.lost(
187
206
  "row",
188
207
  len(rows),
@@ -98,10 +98,11 @@ class ElasticsearchSink(HTTPSink):
98
98
  Raises:
99
99
  None.
100
100
  """
101
- return SinkLosses(
102
- dropped=self.dropped_oversized,
103
- failed=self.failed + self.item_errors + self.dropped_unadjudicated,
104
- )
101
+ with self._counter_lock:
102
+ return SinkLosses(
103
+ dropped=self.dropped_oversized,
104
+ failed=self.failed + self.item_errors + self.dropped_unadjudicated,
105
+ )
105
106
 
106
107
  def _parse_bulk_response(self, payload: bytes, sent: int) -> bool:
107
108
  """Counts rejected items and reports whether the response proves nothing was indexed.
@@ -139,7 +140,8 @@ class ElasticsearchSink(HTTPSink):
139
140
  items = usable_results(data.get("items"))
140
141
  errors = sum(1 for item in items if _has_error(item))
141
142
  if len(items) != sent or not errors:
142
- self.dropped_unadjudicated += sent
143
+ with self._counter_lock:
144
+ self.dropped_unadjudicated += sent
143
145
  _diag.lost(
144
146
  "event",
145
147
  sent,
@@ -148,7 +150,8 @@ class ElasticsearchSink(HTTPSink):
148
150
  f"not retried",
149
151
  )
150
152
  return False
151
- self.item_errors += errors
153
+ with self._counter_lock:
154
+ self.item_errors += errors
152
155
  _diag.lost("bulk item", errors, f"{type(self).__name__}, rejected by the server")
153
156
  return errors == sent
154
157
 
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._retry import wait
@@ -69,6 +67,9 @@ class AzureEventHubsSink:
69
67
  self.stop_signal: threading.Event | None = None
70
68
  self.failed = 0
71
69
  self.dropped_oversized = 0
70
+ self._counter_lock = threading.Lock()
71
+ self._lock = threading.Lock()
72
+ self._closed = False
72
73
 
73
74
  def losses(self) -> SinkLosses:
74
75
  """Reports oversized drops and events in a batch abandoned past the bound (FR-002).
@@ -82,7 +83,8 @@ class AzureEventHubsSink:
82
83
  Raises:
83
84
  None.
84
85
  """
85
- return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
86
+ with self._counter_lock:
87
+ return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
86
88
 
87
89
  def emit(self, batch: list[dict[str, object]]) -> None:
88
90
  """Packs events into batches within the size limit and sends each (FR-009).
@@ -106,6 +108,32 @@ class AzureEventHubsSink:
106
108
  """
107
109
  if not batch:
108
110
  return
111
+ with self._lock:
112
+ if self._closed:
113
+ raise SinkDeliveryError(
114
+ f"AzureEventHubsSink sent none of {len(batch)} event(s): the sink is closed"
115
+ )
116
+ self._send_batch(batch)
117
+
118
+ def _send_batch(self, batch: list[dict[str, object]]) -> None:
119
+ """Packs and sends the batch, with the emit lock already held.
120
+
121
+ Split out so the lock's extent is one line in :meth:`emit`. The driver requirement
122
+ satisfied (SPEC-028 FR-002): Microsoft affirmatively states that the
123
+ ``EventHubProducerClient`` is not thread-safe and recommends guarding it with a
124
+ ``threading.Lock``; the ``EventDataBatch`` this builds up across the loop is single-owner
125
+ state besides — two threads packing into batches from one producer interleave
126
+ ``create_batch``/``add``/``send`` on it.
127
+
128
+ Args:
129
+ batch: The events to send, known non-empty.
130
+
131
+ Returns:
132
+ None.
133
+
134
+ Raises:
135
+ SinkDeliveryError: When every attempted send failed.
136
+ """
109
137
  event_data_cls = _event_data_cls()
110
138
  current = self.producer.create_batch()
111
139
  attempted = delivered = 0
@@ -118,7 +146,8 @@ class AzureEventHubsSink:
118
146
  delivered += self._send(current)
119
147
  current = self.producer.create_batch()
120
148
  if not _try_add(current, data):
121
- self.dropped_oversized += 1
149
+ with self._counter_lock:
150
+ self.dropped_oversized += 1
122
151
  _diag.lost("event", 1, "AzureEventHubsSink, too large for an empty 1 MB batch")
123
152
  if len(current) > 0:
124
153
  attempted += 1
@@ -131,6 +160,9 @@ class AzureEventHubsSink:
131
160
  def close(self) -> None:
132
161
  """Closes the producer (FR-009).
133
162
 
163
+ Idempotent, and takes the emit lock so the producer is never closed out from under an
164
+ in-flight send (SPEC-028 FR-002).
165
+
134
166
  Args:
135
167
  None.
136
168
 
@@ -140,7 +172,11 @@ class AzureEventHubsSink:
140
172
  Raises:
141
173
  Exception: Whatever the producer raises on close.
142
174
  """
143
- self.producer.close()
175
+ with self._lock:
176
+ if self._closed:
177
+ return
178
+ self._closed = True
179
+ self.producer.close()
144
180
 
145
181
  def _send(self, event_batch: Any) -> int:
146
182
  """Sends one ``EventDataBatch``, retrying failures (FR-009, FR-011).
@@ -167,7 +203,8 @@ class AzureEventHubsSink:
167
203
  if attempt < self.max_retries:
168
204
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
169
205
  continue
170
- self.failed += len(event_batch)
206
+ with self._counter_lock:
207
+ self.failed += len(event_batch)
171
208
  _diag.lost(
172
209
  "event",
173
210
  len(event_batch),
@@ -8,6 +8,8 @@ import threading
8
8
  import time
9
9
  from typing import TextIO
10
10
 
11
+ from log_foundry.sinks.base import SinkDeliveryError
12
+
11
13
  __all__ = ["FileSink", "RotatingFileSink"]
12
14
 
13
15
  _WHEN_SECONDS = {
@@ -71,7 +73,13 @@ class FileSink:
71
73
  Raises:
72
74
  OSError: If the write or flush fails (FR-001).
73
75
  """
76
+ if not batch:
77
+ return
74
78
  with self._lock:
79
+ if self._closed:
80
+ raise SinkDeliveryError(
81
+ f"FileSink wrote none of {len(batch)} event(s): the sink is closed"
82
+ )
75
83
  for event in batch:
76
84
  self._stream.write(json.dumps(event) + "\n")
77
85
  self._stream.flush()
@@ -94,9 +102,9 @@ class FileSink:
94
102
  with self._lock:
95
103
  if self._closed:
96
104
  return
105
+ self._closed = True
97
106
  self._stream.flush()
98
107
  self._stream.close()
99
- self._closed = True
100
108
 
101
109
 
102
110
  class RotatingFileSink:
@@ -176,7 +184,13 @@ class RotatingFileSink:
176
184
  Raises:
177
185
  OSError: If a write, flush or rotation fails.
178
186
  """
187
+ if not batch:
188
+ return
179
189
  with self._lock:
190
+ if self._closed:
191
+ raise SinkDeliveryError(
192
+ f"RotatingFileSink wrote none of {len(batch)} event(s): the sink is closed"
193
+ )
180
194
  for event in batch:
181
195
  line = json.dumps(event) + "\n"
182
196
  data = len(line.encode(self._encoding))
@@ -205,9 +219,9 @@ class RotatingFileSink:
205
219
  with self._lock:
206
220
  if self._closed:
207
221
  return
222
+ self._closed = True
208
223
  self._stream.flush()
209
224
  self._stream.close()
210
- self._closed = True
211
225
 
212
226
  @staticmethod
213
227
  def _rollover_seconds(when: str | None, interval: int) -> float | None:
@@ -3,10 +3,8 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import json
6
- from typing import TYPE_CHECKING, Any
7
-
8
- if TYPE_CHECKING:
9
- import threading
6
+ import threading
7
+ from typing import Any
10
8
 
11
9
  from log_foundry import _diag
12
10
  from log_foundry.sinks._batch import adjudicate_positional, usable_results
@@ -34,6 +32,10 @@ class FirehoseSink:
34
32
  the chunk sent, so none could be paired to an outcome. A non-zero count means those
35
33
  records were abandoned without the stream ever confirming them — treat it as loss, and
36
34
  as a sign the client is not AWS-shaped.
35
+
36
+ The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport
37
+ lock. ``boto3`` clients are documented thread-safe, this one is built once in
38
+ ``__init__``, and the sink rebinds nothing after construction.
37
39
  """
38
40
 
39
41
  MAX_RECORDS = 500
@@ -67,6 +69,7 @@ class FirehoseSink:
67
69
  self.failed = 0
68
70
  self.dropped_oversized = 0
69
71
  self.dropped_unadjudicated = 0
72
+ self._counter_lock = threading.Lock()
70
73
 
71
74
  def emit(self, batch: list[dict[str, object]]) -> None:
72
75
  """Re-chunks to the request limits and sends each chunk, retrying failures (FR-004).
@@ -126,10 +129,11 @@ class FirehoseSink:
126
129
  Raises:
127
130
  None.
128
131
  """
129
- return SinkLosses(
130
- dropped=self.dropped_oversized,
131
- failed=self.failed + self.dropped_unadjudicated,
132
- )
132
+ with self._counter_lock:
133
+ return SinkLosses(
134
+ dropped=self.dropped_oversized,
135
+ failed=self.failed + self.dropped_unadjudicated,
136
+ )
133
137
 
134
138
  def close(self) -> None:
135
139
  """Does nothing, since the sink buffers nothing internally (FR-001).
@@ -160,7 +164,8 @@ class FirehoseSink:
160
164
  for event in batch:
161
165
  data = json.dumps(event).encode("utf-8")
162
166
  if len(data) > self.MAX_RECORD_BYTES:
163
- self.dropped_oversized += 1
167
+ with self._counter_lock:
168
+ self.dropped_oversized += 1
164
169
  _diag.lost(
165
170
  "event",
166
171
  1,
@@ -201,7 +206,8 @@ class FirehoseSink:
201
206
  results = usable_results(response.get("RequestResponses"))
202
207
  verdict = adjudicate_positional(records, results)
203
208
  if verdict.unadjudicated:
204
- self.dropped_unadjudicated += verdict.unadjudicated
209
+ with self._counter_lock:
210
+ self.dropped_unadjudicated += verdict.unadjudicated
205
211
  _diag.lost(
206
212
  "record",
207
213
  verdict.unadjudicated,
@@ -216,7 +222,8 @@ class FirehoseSink:
216
222
  if attempt < self.max_retries:
217
223
  wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
218
224
  if attempt >= self.max_retries:
219
- self.failed += len(records)
225
+ with self._counter_lock:
226
+ self.failed += len(records)
220
227
  _diag.lost(
221
228
  "record",
222
229
  len(records),
@@ -4,6 +4,7 @@ from __future__ import annotations
4
4
 
5
5
  import gzip as _gzip
6
6
  import json
7
+ import threading
7
8
  import urllib.error
8
9
  import urllib.request
9
10
  from base64 import b64encode
@@ -14,7 +15,6 @@ from log_foundry.sinks._retry import clamp_server_delay, wait
14
15
  from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
15
16
 
16
17
  if TYPE_CHECKING:
17
- import threading
18
18
  from collections.abc import Callable
19
19
 
20
20
  __all__ = ["HTTPSink", "merge_headers"]
@@ -129,6 +129,7 @@ class HTTPSink:
129
129
  self._opener = opener if opener is not None else urllib.request.urlopen
130
130
  self.failed = 0
131
131
  self.dropped_oversized = 0
132
+ self._counter_lock = threading.Lock()
132
133
 
133
134
  def emit(self, batch: list[dict[str, object]]) -> None:
134
135
  """Serializes the batch per the configured body format and POSTs it (FR-001).
@@ -161,7 +162,8 @@ class HTTPSink:
161
162
  Raises:
162
163
  None.
163
164
  """
164
- return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
165
+ with self._counter_lock:
166
+ return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
165
167
 
166
168
  def close(self) -> None:
167
169
  """Does nothing, since ``urllib`` opens a fresh connection per request (FR-012).
@@ -377,7 +379,8 @@ class HTTPSink:
377
379
  Raises:
378
380
  SinkDeliveryError: Always.
379
381
  """
380
- self.failed += 1
382
+ with self._counter_lock:
383
+ self.failed += 1
381
384
  detail = f"{type(self).__name__}, {self.max_retries + 1} attempt(s), {reason}"
382
385
  _diag.lost("request", 1, detail)
383
386
  raise SinkDeliveryError(detail)