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.
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/PKG-INFO +28 -10
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/README.md +27 -9
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_socket.py +21 -4
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/base.py +9 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/clickhouse.py +34 -15
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/elasticsearch.py +9 -6
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/eventhubs.py +45 -8
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/file.py +16 -2
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/firehose.py +18 -11
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/http.py +6 -3
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/kafka.py +12 -3
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/kinesis.py +18 -11
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/mongodb.py +49 -15
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/multi.py +6 -4
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/nats.py +27 -9
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/postgres.py +49 -13
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/pubsub.py +29 -7
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/rabbitmq.py +40 -16
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/redis.py +14 -6
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sentry.py +16 -8
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sns.py +13 -7
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sqlite.py +37 -4
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/sqs.py +18 -8
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/util.py +4 -1
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/worker.py +19 -4
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/LICENSE +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/syslog.py +0 -0
- {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.
|
|
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
|
|
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
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
self.
|
|
149
|
-
|
|
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.
|
|
203
|
+
with self._counter_lock:
|
|
204
|
+
self.failed += len(rows)
|
|
186
205
|
_diag.lost(
|
|
187
206
|
"row",
|
|
188
207
|
len(rows),
|
{log_foundry-0.10.2.dev23 → log_foundry-0.10.2.dev25}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
@@ -98,10 +98,11 @@ class ElasticsearchSink(HTTPSink):
|
|
|
98
98
|
Raises:
|
|
99
99
|
None.
|
|
100
100
|
"""
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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)
|