log-foundry 0.10.2.dev24__tar.gz → 0.10.2.dev26__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.dev24 → log_foundry-0.10.2.dev26}/PKG-INFO +53 -4
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/README.md +52 -3
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/__init__.py +23 -5
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/config.py +52 -1
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/decorator.py +34 -1
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_socket.py +16 -2
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/clickhouse.py +10 -5
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/eventhubs.py +36 -1
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/file.py +16 -2
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/firehose.py +4 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/kafka.py +4 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/kinesis.py +4 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/mongodb.py +28 -7
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/nats.py +21 -7
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/postgres.py +5 -1
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/pubsub.py +21 -4
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/rabbitmq.py +33 -10
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/redis.py +7 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sentry.py +5 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sns.py +4 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sqlite.py +37 -4
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/sqs.py +7 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/worker.py +241 -8
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/LICENSE +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.10.2.dev24 → log_foundry-0.10.2.dev26}/src/log_foundry/sinks/util.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.dev26
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -234,6 +234,34 @@ lf.configure(
|
|
|
234
234
|
If you never set a `sink`, the first decorated call falls back to `StdoutSink()`, so `@trace`
|
|
235
235
|
works with zero configuration.
|
|
236
236
|
|
|
237
|
+
**Passing `sink=` after logging has started swaps the live destination.** The background worker
|
|
238
|
+
captures its sink when it is built, so a later `configure(sink=...)` has to do more than update
|
|
239
|
+
what `get_config()` reports — otherwise the config and the behaviour disagree silently, which is
|
|
240
|
+
what it used to do. The swap drains everything submitted so far to the **previous** sink, closes
|
|
241
|
+
it, and points the worker at the new one:
|
|
242
|
+
|
|
243
|
+
```python
|
|
244
|
+
lf.configure(sink=StdoutSink())
|
|
245
|
+
do_some_work() # these events go to stdout
|
|
246
|
+
|
|
247
|
+
lf.configure(sink=SQSSink(queue_url=QUEUE_URL))
|
|
248
|
+
do_some_more_work() # these go to SQS; the earlier ones were drained to stdout
|
|
249
|
+
```
|
|
250
|
+
|
|
251
|
+
The drain is bounded at 5 s. If it cannot be confirmed in that time the swap still takes effect —
|
|
252
|
+
you asked for that sink — but the previous sink is left **open** rather than closed, because the
|
|
253
|
+
drain thread may still be inside its `emit`, and `health().incomplete_swaps` records it. Passing
|
|
254
|
+
the sink that is already live is a no-op: no drain, no close. The previous sink **is** closed, so
|
|
255
|
+
do not hand it back to a later call.
|
|
256
|
+
|
|
257
|
+
The 5 s covers the drain, **not** the previous sink's `close()`, which has no timeout of its own —
|
|
258
|
+
a destination that blocks there blocks `configure()`. `KafkaSink.close()` flushes its producer, so
|
|
259
|
+
an unreachable broker is the case to watch. Configure the sink before the first log where you can;
|
|
260
|
+
that path has no worker to retarget and nothing to close.
|
|
261
|
+
|
|
262
|
+
`configure()` is still a startup call. It is not thread-safe, and a span finishing on another
|
|
263
|
+
thread mid-swap may land on either sink.
|
|
264
|
+
|
|
237
265
|
### `@trace`
|
|
238
266
|
|
|
239
267
|
Decorate any **synchronous** function. Usable bare or with arguments:
|
|
@@ -746,7 +774,11 @@ returns a snapshot of the worker's counters:
|
|
|
746
774
|
|
|
747
775
|
```python
|
|
748
776
|
h = log_foundry.health()
|
|
749
|
-
if
|
|
777
|
+
if (
|
|
778
|
+
h.dropped or h.failed_batches or h.stopped_reason or h.incomplete_swaps
|
|
779
|
+
or (h.sink and (h.sink.dropped or h.sink.failed))
|
|
780
|
+
or (h.retired and h.submitted_after_shutdown)
|
|
781
|
+
):
|
|
750
782
|
... # logs were silently lost — worth an alert
|
|
751
783
|
```
|
|
752
784
|
|
|
@@ -759,6 +791,8 @@ They tell you different things, and they want different responses:
|
|
|
759
791
|
| `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
|
|
760
792
|
| `sink.dropped` | The sink discarded events **before** attempting delivery — an oversized record, or one the client refused outright. | Read the stderr line: it names the cause. An oversized record means shrink what you log; a refused local produce/publish (Kafka, Pub/Sub) points at the client — a saturated buffer, a bad topic, a credential. |
|
|
761
793
|
| `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
|
|
794
|
+
| `retired` + `submitted_after_shutdown` | `shutdown()` was called and the process **kept logging**. Those events are queued where nothing will drain them — total loss, for as long as the process runs. | Use `flush()`, not `shutdown()`, in a process that logs again. This is the serverless mistake below. |
|
|
795
|
+
| `incomplete_swaps` | A late `configure(sink=...)` could not confirm the previous sink was drained. The swap took effect; that sink was left open and some queued events may have gone to the new one. | Investigate the previous sink — it was hung or failing. Configure the sink before the first log where you can. |
|
|
762
796
|
|
|
763
797
|
`h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
|
|
764
798
|
the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
|
|
@@ -780,8 +814,14 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
|
|
|
780
814
|
thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
|
|
781
815
|
pointing at the wrong fix.
|
|
782
816
|
|
|
817
|
+
`retired` is deliberately **not** alerted on by itself. A process that shuts down and then stops
|
|
818
|
+
logging is doing the right thing; it is the *pair* — retired, and still being handed events — that
|
|
819
|
+
means every log line since the shutdown has gone nowhere. That state used to read as perfectly
|
|
820
|
+
healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
|
|
821
|
+
|
|
783
822
|
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
784
|
-
fields over time — a fourth (`stopped_reason`) in `v0.7.0
|
|
823
|
+
fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus a sixth,
|
|
824
|
+
seventh and eighth (`retired`, `submitted_after_shutdown`, `incomplete_swaps`) not yet in a tagged
|
|
785
825
|
release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
|
|
786
826
|
field keeps its position when a new one is appended, so attribute and index access stay stable.
|
|
787
827
|
|
|
@@ -859,6 +899,13 @@ getting them wrong is silent:
|
|
|
859
899
|
the first invocation on a warm container would log and every later one would silently log
|
|
860
900
|
nothing. That failure reads as "works locally, broken in production".
|
|
861
901
|
|
|
902
|
+
It is no longer silent. Logging after `shutdown()` is accepted and undeliverable, and
|
|
903
|
+
`health()` says so: **`retired` is `True` and `submitted_after_shutdown` is non-zero** — that
|
|
904
|
+
pair, and only that pair, is this mistake. The first such submission also writes one stderr
|
|
905
|
+
line naming `flush()` as the remedy, throttled to the first and every thousandth after it.
|
|
906
|
+
`stopped_reason` stays `None` throughout, because nothing failed; someone used the terminal
|
|
907
|
+
drain where the repeatable one belonged.
|
|
908
|
+
|
|
862
909
|
```python
|
|
863
910
|
import log_foundry as lf
|
|
864
911
|
from log_foundry.sinks.sqs import SQSSink
|
|
@@ -876,9 +923,11 @@ def handler(event, context):
|
|
|
876
923
|
# would log nothing.
|
|
877
924
|
drained = lf.flush()
|
|
878
925
|
h = lf.health()
|
|
879
|
-
if not drained or h.failed_batches or h.dropped or h.stopped_reason:
|
|
926
|
+
if not drained or h.failed_batches or h.dropped or h.stopped_reason or h.retired:
|
|
880
927
|
# `drained` covers this invocation's tail; the counters cover anything the worker
|
|
881
928
|
# lost earlier — a batch its own interval trigger already gave up on, for instance.
|
|
929
|
+
# `h.retired` catches the mistake above: inside a handler it can only mean something
|
|
930
|
+
# called shutdown(), and from here on this container logs nothing.
|
|
882
931
|
# Emitting this through your platform's own logger keeps it outside the pipeline
|
|
883
932
|
# that just failed.
|
|
884
933
|
print(f"log-foundry: undelivered logs ({drained=}, {h=})")
|
|
@@ -198,6 +198,34 @@ lf.configure(
|
|
|
198
198
|
If you never set a `sink`, the first decorated call falls back to `StdoutSink()`, so `@trace`
|
|
199
199
|
works with zero configuration.
|
|
200
200
|
|
|
201
|
+
**Passing `sink=` after logging has started swaps the live destination.** The background worker
|
|
202
|
+
captures its sink when it is built, so a later `configure(sink=...)` has to do more than update
|
|
203
|
+
what `get_config()` reports — otherwise the config and the behaviour disagree silently, which is
|
|
204
|
+
what it used to do. The swap drains everything submitted so far to the **previous** sink, closes
|
|
205
|
+
it, and points the worker at the new one:
|
|
206
|
+
|
|
207
|
+
```python
|
|
208
|
+
lf.configure(sink=StdoutSink())
|
|
209
|
+
do_some_work() # these events go to stdout
|
|
210
|
+
|
|
211
|
+
lf.configure(sink=SQSSink(queue_url=QUEUE_URL))
|
|
212
|
+
do_some_more_work() # these go to SQS; the earlier ones were drained to stdout
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The drain is bounded at 5 s. If it cannot be confirmed in that time the swap still takes effect —
|
|
216
|
+
you asked for that sink — but the previous sink is left **open** rather than closed, because the
|
|
217
|
+
drain thread may still be inside its `emit`, and `health().incomplete_swaps` records it. Passing
|
|
218
|
+
the sink that is already live is a no-op: no drain, no close. The previous sink **is** closed, so
|
|
219
|
+
do not hand it back to a later call.
|
|
220
|
+
|
|
221
|
+
The 5 s covers the drain, **not** the previous sink's `close()`, which has no timeout of its own —
|
|
222
|
+
a destination that blocks there blocks `configure()`. `KafkaSink.close()` flushes its producer, so
|
|
223
|
+
an unreachable broker is the case to watch. Configure the sink before the first log where you can;
|
|
224
|
+
that path has no worker to retarget and nothing to close.
|
|
225
|
+
|
|
226
|
+
`configure()` is still a startup call. It is not thread-safe, and a span finishing on another
|
|
227
|
+
thread mid-swap may land on either sink.
|
|
228
|
+
|
|
201
229
|
### `@trace`
|
|
202
230
|
|
|
203
231
|
Decorate any **synchronous** function. Usable bare or with arguments:
|
|
@@ -710,7 +738,11 @@ returns a snapshot of the worker's counters:
|
|
|
710
738
|
|
|
711
739
|
```python
|
|
712
740
|
h = log_foundry.health()
|
|
713
|
-
if
|
|
741
|
+
if (
|
|
742
|
+
h.dropped or h.failed_batches or h.stopped_reason or h.incomplete_swaps
|
|
743
|
+
or (h.sink and (h.sink.dropped or h.sink.failed))
|
|
744
|
+
or (h.retired and h.submitted_after_shutdown)
|
|
745
|
+
):
|
|
714
746
|
... # logs were silently lost — worth an alert
|
|
715
747
|
```
|
|
716
748
|
|
|
@@ -723,6 +755,8 @@ They tell you different things, and they want different responses:
|
|
|
723
755
|
| `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
|
|
724
756
|
| `sink.dropped` | The sink discarded events **before** attempting delivery — an oversized record, or one the client refused outright. | Read the stderr line: it names the cause. An oversized record means shrink what you log; a refused local produce/publish (Kafka, Pub/Sub) points at the client — a saturated buffer, a bad topic, a credential. |
|
|
725
757
|
| `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
|
|
758
|
+
| `retired` + `submitted_after_shutdown` | `shutdown()` was called and the process **kept logging**. Those events are queued where nothing will drain them — total loss, for as long as the process runs. | Use `flush()`, not `shutdown()`, in a process that logs again. This is the serverless mistake below. |
|
|
759
|
+
| `incomplete_swaps` | A late `configure(sink=...)` could not confirm the previous sink was drained. The swap took effect; that sink was left open and some queued events may have gone to the new one. | Investigate the previous sink — it was hung or failing. Configure the sink before the first log where you can. |
|
|
726
760
|
|
|
727
761
|
`h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
|
|
728
762
|
the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
|
|
@@ -744,8 +778,14 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
|
|
|
744
778
|
thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
|
|
745
779
|
pointing at the wrong fix.
|
|
746
780
|
|
|
781
|
+
`retired` is deliberately **not** alerted on by itself. A process that shuts down and then stops
|
|
782
|
+
logging is doing the right thing; it is the *pair* — retired, and still being handed events — that
|
|
783
|
+
means every log line since the shutdown has gone nowhere. That state used to read as perfectly
|
|
784
|
+
healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
|
|
785
|
+
|
|
747
786
|
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
748
|
-
fields over time — a fourth (`stopped_reason`) in `v0.7.0
|
|
787
|
+
fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus a sixth,
|
|
788
|
+
seventh and eighth (`retired`, `submitted_after_shutdown`, `incomplete_swaps`) not yet in a tagged
|
|
749
789
|
release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
|
|
750
790
|
field keeps its position when a new one is appended, so attribute and index access stay stable.
|
|
751
791
|
|
|
@@ -823,6 +863,13 @@ getting them wrong is silent:
|
|
|
823
863
|
the first invocation on a warm container would log and every later one would silently log
|
|
824
864
|
nothing. That failure reads as "works locally, broken in production".
|
|
825
865
|
|
|
866
|
+
It is no longer silent. Logging after `shutdown()` is accepted and undeliverable, and
|
|
867
|
+
`health()` says so: **`retired` is `True` and `submitted_after_shutdown` is non-zero** — that
|
|
868
|
+
pair, and only that pair, is this mistake. The first such submission also writes one stderr
|
|
869
|
+
line naming `flush()` as the remedy, throttled to the first and every thousandth after it.
|
|
870
|
+
`stopped_reason` stays `None` throughout, because nothing failed; someone used the terminal
|
|
871
|
+
drain where the repeatable one belonged.
|
|
872
|
+
|
|
826
873
|
```python
|
|
827
874
|
import log_foundry as lf
|
|
828
875
|
from log_foundry.sinks.sqs import SQSSink
|
|
@@ -840,9 +887,11 @@ def handler(event, context):
|
|
|
840
887
|
# would log nothing.
|
|
841
888
|
drained = lf.flush()
|
|
842
889
|
h = lf.health()
|
|
843
|
-
if not drained or h.failed_batches or h.dropped or h.stopped_reason:
|
|
890
|
+
if not drained or h.failed_batches or h.dropped or h.stopped_reason or h.retired:
|
|
844
891
|
# `drained` covers this invocation's tail; the counters cover anything the worker
|
|
845
892
|
# lost earlier — a batch its own interval trigger already gave up on, for instance.
|
|
893
|
+
# `h.retired` catches the mistake above: inside a handler it can only mean something
|
|
894
|
+
# called shutdown(), and from here on this container logs nothing.
|
|
846
895
|
# Emitting this through your platform's own logger keeps it outside the pipeline
|
|
847
896
|
# that just failed.
|
|
848
897
|
print(f"log-foundry: undelivered logs ({drained=}, {h=})")
|
|
@@ -58,22 +58,35 @@ def health() -> Health:
|
|
|
58
58
|
code non-blocking, and a non-zero ``failed_batches`` means a sink stayed broken through the
|
|
59
59
|
whole retry budget — both are losses the library absorbs on purpose, and this is how you
|
|
60
60
|
notice them. A non-``None`` ``stopped_reason`` is worse than either: the background thread
|
|
61
|
-
died on that exception type, so nothing further will be delivered at all (SPEC-019)
|
|
61
|
+
died on that exception type, so nothing further will be delivered at all (SPEC-019). A
|
|
62
|
+
``retired`` worker still being handed events is the same total loss arrived at from the
|
|
63
|
+
other direction — someone called :func:`shutdown` in a process that logs again (SPEC-030)::
|
|
62
64
|
|
|
63
65
|
h = log_foundry.health()
|
|
64
|
-
if h.dropped or h.failed_batches or h.stopped_reason or (
|
|
66
|
+
if h.dropped or h.failed_batches or h.stopped_reason or h.incomplete_swaps or (
|
|
65
67
|
h.sink and (h.sink.dropped or h.sink.failed)
|
|
66
|
-
):
|
|
68
|
+
) or (h.retired and h.submitted_after_shutdown):
|
|
67
69
|
... # raise an alert; logs were silently lost
|
|
68
70
|
|
|
71
|
+
``retired`` alone is not a fault — a process that shuts down and then stops logging is
|
|
72
|
+
doing the right thing, which is why it is paired with the count rather than alerted on.
|
|
73
|
+
|
|
69
74
|
Args:
|
|
70
75
|
None.
|
|
71
76
|
|
|
72
77
|
Returns:
|
|
73
|
-
The snapshot: ``queued``, ``dropped``, ``failed_batches``, ``stopped_reason``
|
|
74
|
-
``
|
|
78
|
+
The snapshot: ``queued``, ``dropped``, ``failed_batches``, ``stopped_reason``, ``sink``,
|
|
79
|
+
``retired``, ``submitted_after_shutdown`` and ``incomplete_swaps``. ``retired`` says
|
|
80
|
+
:func:`shutdown` was called, and ``submitted_after_shutdown`` counts events accepted
|
|
81
|
+
afterwards, which are queued where nothing will drain them — non-zero together, that is
|
|
82
|
+
the ``shutdown()``-per-invocation mistake, and the remedy is :func:`flush`.
|
|
83
|
+
``incomplete_swaps`` counts late ``configure(sink=...)`` calls whose drain of the
|
|
84
|
+
previous sink could not be confirmed, leaving that sink open. ``sink`` is the configured
|
|
85
|
+
sink's own
|
|
75
86
|
:class:`~log_foundry.sinks.base.SinkLosses` — loss the sink absorbed rather than the
|
|
76
87
|
worker (SPEC-026) — and is ``None`` when no worker exists or the sink reports nothing.
|
|
88
|
+
Note ``sink`` describes whichever sink is live now, so a swap takes the previous sink's
|
|
89
|
+
absorbed losses out of the snapshot with it.
|
|
77
90
|
Its ``dropped`` is not the worker's: the worker's is backpressure at the queue, the
|
|
78
91
|
sink's is an event that never reached the wire, and the stderr line names which. Its
|
|
79
92
|
``failed`` is an upper bound on loss rather than a count of it, since a sink that raises
|
|
@@ -98,6 +111,11 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
|
98
111
|
registered via ``atexit``, so call it explicitly only when you want to be certain the tail
|
|
99
112
|
of the queue reached the sink before a fast process exit (SPEC-004 FR-005).
|
|
100
113
|
|
|
114
|
+
Logging afterwards is **accepted and undeliverable**: those events are queued and nothing
|
|
115
|
+
will drain them. It is not silent any more — :func:`health` then reports ``retired`` with a
|
|
116
|
+
non-zero ``submitted_after_shutdown``, and the first such submission writes one stderr line
|
|
117
|
+
(SPEC-030). That pair is the reading that catches the mistake above.
|
|
118
|
+
|
|
101
119
|
Args:
|
|
102
120
|
timeout: Seconds bounding the wait for the background thread (SPEC-027 FR-004). ``None``
|
|
103
121
|
waits indefinitely, which is what this did unconditionally before and is still
|
|
@@ -76,11 +76,31 @@ def configure(
|
|
|
76
76
|
the zero-dependency dev default (arch §8). Every ceiling is validated before anything is
|
|
77
77
|
assigned, so a rejected call leaves the config exactly as it found it.
|
|
78
78
|
|
|
79
|
+
A ``sink=`` passed after logging has already started is the one argument that needs more
|
|
80
|
+
than an assignment, because the background worker captured its sink when it was built
|
|
81
|
+
(arch §7). It **swaps the live delivery target**: everything submitted so far is drained to
|
|
82
|
+
the previous sink, that sink is closed, and subsequent events go to the new one. The drains
|
|
83
|
+
are bounded, and a swap whose drain could not be confirmed leaves the previous sink open and
|
|
84
|
+
records ``health().incomplete_swaps`` (SPEC-030 FR-003) — so "repeated calls compose rather
|
|
85
|
+
than reset" holds for the sink too, at the cost of one bounded wait. Passing the sink that
|
|
86
|
+
is already live is a no-op: no drain, no close. The previous sink is closed and must not be
|
|
87
|
+
handed back to a later call.
|
|
88
|
+
|
|
89
|
+
The *closing* of the previous sink is not bounded, because ``Sink.close`` takes no timeout —
|
|
90
|
+
a destination that blocks in ``close()`` blocks this call. It is the same gap
|
|
91
|
+
``architecture.md`` §13 already records for ``shutdown()``, and it has the same fix, which
|
|
92
|
+
is a change to the sink contract rather than to this function.
|
|
93
|
+
|
|
94
|
+
This is still a startup call. It is not thread-safe, and a span finishing on another thread
|
|
95
|
+
during a swap may land on either sink.
|
|
96
|
+
|
|
79
97
|
Args:
|
|
80
98
|
service: The service name stamped onto every event.
|
|
81
99
|
version: The service version stamped onto every event.
|
|
82
100
|
env: The deployment environment stamped onto every event.
|
|
83
|
-
sink: The destination every event is delivered to.
|
|
101
|
+
sink: The destination every event is delivered to. Passed after the first log, it swaps
|
|
102
|
+
the live target as described above rather than only updating what ``get_config()``
|
|
103
|
+
reports.
|
|
84
104
|
defaults: Fields merged into every event at the lowest precedence.
|
|
85
105
|
max_value_bytes: Per-value ceiling, in UTF-8 bytes or rendered digits.
|
|
86
106
|
max_stack_bytes: Ceiling for ``error.stack`` alone.
|
|
@@ -119,6 +139,37 @@ def configure(
|
|
|
119
139
|
|
|
120
140
|
_ensure_sink()
|
|
121
141
|
|
|
142
|
+
if sink is not None:
|
|
143
|
+
_swap_live_sink(sink)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _swap_live_sink(sink: Sink) -> None:
|
|
147
|
+
"""Points an already-running worker at a newly configured sink (SPEC-030 FR-003).
|
|
148
|
+
|
|
149
|
+
The import is local for the reason ``_ensure_sink``'s is: ``decorator`` imports this module
|
|
150
|
+
at module scope, so reaching back the other way at import time would be a cycle. It also
|
|
151
|
+
keeps the dependency to the one call that needs it — ``configure()`` without a ``sink=``
|
|
152
|
+
never touches the worker at all.
|
|
153
|
+
|
|
154
|
+
The budget is passed explicitly rather than left to the parameter default, so that the
|
|
155
|
+
bound this call actually applies is resolved when it runs. A default argument is bound at
|
|
156
|
+
definition time, which would leave the end-to-end bound untestable without reaching past
|
|
157
|
+
the function under test.
|
|
158
|
+
|
|
159
|
+
Args:
|
|
160
|
+
sink: The sink just written to the config.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
None.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
None.
|
|
167
|
+
"""
|
|
168
|
+
from log_foundry.decorator import _swap_sink
|
|
169
|
+
from log_foundry.worker import DEFAULT_SWAP_TIMEOUT
|
|
170
|
+
|
|
171
|
+
_swap_sink(sink, DEFAULT_SWAP_TIMEOUT)
|
|
172
|
+
|
|
122
173
|
|
|
123
174
|
def get_config() -> Config:
|
|
124
175
|
"""Returns the current global config singleton.
|
|
@@ -20,11 +20,13 @@ from log_foundry.ids import (
|
|
|
20
20
|
parse_traceparent,
|
|
21
21
|
)
|
|
22
22
|
from log_foundry.model import Span, backfill_baggage, end_event, start_event
|
|
23
|
-
from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health, Worker
|
|
23
|
+
from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, DEFAULT_SWAP_TIMEOUT, Health, Worker
|
|
24
24
|
|
|
25
25
|
if TYPE_CHECKING:
|
|
26
26
|
import contextvars
|
|
27
27
|
|
|
28
|
+
from log_foundry.sinks.base import Sink
|
|
29
|
+
|
|
28
30
|
__all__ = ["continue_trace", "trace"]
|
|
29
31
|
|
|
30
32
|
_worker: Worker | None = None
|
|
@@ -231,6 +233,37 @@ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
|
231
233
|
_worker.shutdown(timeout)
|
|
232
234
|
|
|
233
235
|
|
|
236
|
+
def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> None:
|
|
237
|
+
"""Retargets the process worker at a new sink, backing a late ``configure(sink=...)``.
|
|
238
|
+
|
|
239
|
+
Like :func:`_flush_worker` this deliberately does not call :func:`_get_worker`: a process
|
|
240
|
+
that has not logged has captured no sink, so there is nothing to swap and building a thread
|
|
241
|
+
to prove it would be pure cost — that is also the case where the old behaviour was already
|
|
242
|
+
correct (SPEC-030 FR-003).
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
new_sink: The sink already written to the config, to be made the live delivery target.
|
|
246
|
+
timeout: Seconds bounding the drain of the previous sink.
|
|
247
|
+
|
|
248
|
+
Returns:
|
|
249
|
+
None.
|
|
250
|
+
|
|
251
|
+
Raises:
|
|
252
|
+
None. This runs inside ``configure()``, which has never raised for anything but a
|
|
253
|
+
rejected ceiling, and a sink swap that fails must not become the reason an application
|
|
254
|
+
cannot start.
|
|
255
|
+
"""
|
|
256
|
+
worker = _worker
|
|
257
|
+
if worker is None:
|
|
258
|
+
return
|
|
259
|
+
try:
|
|
260
|
+
worker.swap_sink(new_sink, timeout)
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
_diag.absorbed(
|
|
263
|
+
"swapping the log sink", exc, "events may still be delivered to the previous sink"
|
|
264
|
+
)
|
|
265
|
+
|
|
266
|
+
|
|
234
267
|
def _flush_worker(timeout: float | None = 5.0) -> bool:
|
|
235
268
|
"""Drains the process worker without retiring it, backing ``flush()`` (SPEC-013 FR-003).
|
|
236
269
|
|
|
@@ -111,6 +111,7 @@ class SocketTransport:
|
|
|
111
111
|
self._counter_lock = threading.Lock()
|
|
112
112
|
self.stop_signal: threading.Event | None = None
|
|
113
113
|
self._lock = threading.Lock()
|
|
114
|
+
self._closed = False
|
|
114
115
|
|
|
115
116
|
def send_all(self, messages: list[bytes]) -> None:
|
|
116
117
|
"""Sends each pre-framed message, reconnecting on error (FR-005, FR-006).
|
|
@@ -119,6 +120,11 @@ class SocketTransport:
|
|
|
119
120
|
reconnect rebinds, so releasing between messages would let another thread send on a
|
|
120
121
|
socket this one is about to reset (SPEC-028 FR-002).
|
|
121
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
|
+
|
|
122
128
|
Args:
|
|
123
129
|
messages: The exact bytes to put on the wire, one call per message.
|
|
124
130
|
|
|
@@ -130,14 +136,21 @@ class SocketTransport:
|
|
|
130
136
|
this transport propagate a dead destination to the worker instead of reporting
|
|
131
137
|
success (SPEC-026 FR-001). A partial send does not raise, because the worker's retry
|
|
132
138
|
would re-send the messages that already landed, and an empty call is a no-op rather
|
|
133
|
-
than a total failure.
|
|
139
|
+
than a total failure. Also when the transport is already closed.
|
|
134
140
|
"""
|
|
141
|
+
if not messages:
|
|
142
|
+
return
|
|
135
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
|
+
)
|
|
136
149
|
delivered = 0
|
|
137
150
|
for message in messages:
|
|
138
151
|
if self._send_one(message):
|
|
139
152
|
delivered += 1
|
|
140
|
-
if
|
|
153
|
+
if delivered == 0:
|
|
141
154
|
raise SinkDeliveryError(
|
|
142
155
|
f"SocketTransport delivered none of {len(messages)} message(s)"
|
|
143
156
|
)
|
|
@@ -175,6 +188,7 @@ class SocketTransport:
|
|
|
175
188
|
None.
|
|
176
189
|
"""
|
|
177
190
|
with self._lock:
|
|
191
|
+
self._closed = True
|
|
178
192
|
self._reset()
|
|
179
193
|
|
|
180
194
|
def _send_one(self, message: bytes) -> bool:
|
|
@@ -41,10 +41,11 @@ class ClickHouseSink:
|
|
|
41
41
|
sink is write-only, and the worst-case delay (SPEC-027 FR-005) is ``max_retries``
|
|
42
42
|
interruptible waits per chunk, 0.7 s at the defaults.
|
|
43
43
|
|
|
44
|
-
The driver requirement satisfied (SPEC-028 FR-002):
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
48
49
|
connection-pool design FR-002 puts out of scope.
|
|
49
50
|
"""
|
|
50
51
|
|
|
@@ -133,6 +134,10 @@ class ClickHouseSink:
|
|
|
133
134
|
return
|
|
134
135
|
chunks = inserted = 0
|
|
135
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
|
+
)
|
|
136
141
|
for chunk in chunk_list(batch, self._chunk_size):
|
|
137
142
|
chunks += 1
|
|
138
143
|
inserted += self._insert([self._row(event) for event in chunk])
|
|
@@ -157,9 +162,9 @@ class ClickHouseSink:
|
|
|
157
162
|
with self._lock:
|
|
158
163
|
if self._closed:
|
|
159
164
|
return
|
|
165
|
+
self._closed = True
|
|
160
166
|
if self._owns_client:
|
|
161
167
|
self.client.close()
|
|
162
|
-
self._closed = True
|
|
163
168
|
|
|
164
169
|
def _row(self, event: dict[str, object]) -> list[object]:
|
|
165
170
|
"""Builds one row: the extracted columns, then the whole event as JSON.
|
|
@@ -68,6 +68,8 @@ class AzureEventHubsSink:
|
|
|
68
68
|
self.failed = 0
|
|
69
69
|
self.dropped_oversized = 0
|
|
70
70
|
self._counter_lock = threading.Lock()
|
|
71
|
+
self._lock = threading.Lock()
|
|
72
|
+
self._closed = False
|
|
71
73
|
|
|
72
74
|
def losses(self) -> SinkLosses:
|
|
73
75
|
"""Reports oversized drops and events in a batch abandoned past the bound (FR-002).
|
|
@@ -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
|
|
@@ -132,6 +160,9 @@ class AzureEventHubsSink:
|
|
|
132
160
|
def close(self) -> None:
|
|
133
161
|
"""Closes the producer (FR-009).
|
|
134
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
|
+
|
|
135
166
|
Args:
|
|
136
167
|
None.
|
|
137
168
|
|
|
@@ -141,7 +172,11 @@ class AzureEventHubsSink:
|
|
|
141
172
|
Raises:
|
|
142
173
|
Exception: Whatever the producer raises on close.
|
|
143
174
|
"""
|
|
144
|
-
self.
|
|
175
|
+
with self._lock:
|
|
176
|
+
if self._closed:
|
|
177
|
+
return
|
|
178
|
+
self._closed = True
|
|
179
|
+
self.producer.close()
|
|
145
180
|
|
|
146
181
|
def _send(self, event_batch: Any) -> int:
|
|
147
182
|
"""Sends one ``EventDataBatch``, retrying failures (FR-009, FR-011).
|
|
@@ -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:
|
|
@@ -32,6 +32,10 @@ class FirehoseSink:
|
|
|
32
32
|
the chunk sent, so none could be paired to an outcome. A non-zero count means those
|
|
33
33
|
records were abandoned without the stream ever confirming them — treat it as loss, and
|
|
34
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.
|
|
35
39
|
"""
|
|
36
40
|
|
|
37
41
|
MAX_RECORDS = 500
|
|
@@ -24,6 +24,10 @@ class KafkaSink:
|
|
|
24
24
|
failed: Messages whose delivery callback reported an error.
|
|
25
25
|
rejected: Messages ``produce()`` itself refused — a full local queue, a serialization fault
|
|
26
26
|
— which never reached the producer's batch at all.
|
|
27
|
+
|
|
28
|
+
The driver requirement satisfied (SPEC-028 FR-002): this sink takes **no** transport
|
|
29
|
+
lock. ``confluent-kafka`` documents its ``Producer`` as thread-safe, and this sink adds no state
|
|
30
|
+
of its own to guard — ``produce()`` is a local hand-off into the client's internal queue.
|
|
27
31
|
"""
|
|
28
32
|
|
|
29
33
|
def __init__(
|
|
@@ -32,6 +32,10 @@ class KinesisSink:
|
|
|
32
32
|
describe the chunk sent, so none could be paired to an outcome. A non-zero count means
|
|
33
33
|
those records were abandoned without the stream ever confirming them — treat it as loss,
|
|
34
34
|
and 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.
|
|
35
39
|
"""
|
|
36
40
|
|
|
37
41
|
MAX_RECORDS = 500
|