log-foundry 0.10.2.dev18__tar.gz → 0.10.2.dev20__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.dev18 → log_foundry-0.10.2.dev20}/PKG-INFO +99 -16
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/README.md +98 -15
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/__init__.py +29 -6
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/decorator.py +9 -4
- log_foundry-0.10.2.dev20/src/log_foundry/sinks/_retry.py +103 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_socket.py +13 -2
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/base.py +6 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/clickhouse.py +13 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/eventhubs.py +13 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/filtering.py +26 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/firehose.py +17 -1
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/http.py +37 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kinesis.py +17 -1
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logstash.py +24 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/mongodb.py +12 -3
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/multi.py +29 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/postgres.py +13 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/pubsub.py +6 -2
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/rabbitmq.py +13 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/redis.py +13 -4
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sentry.py +22 -1
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sns.py +19 -2
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqs.py +24 -2
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/syslog.py +19 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/transform.py +26 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/worker.py +132 -8
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/LICENSE +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev18 → log_foundry-0.10.2.dev20}/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.dev20
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -438,12 +438,16 @@ A few conventions hold across every sink below:
|
|
|
438
438
|
The tables show the destination-defining arguments only; sinks that retry also take `max_retries`.
|
|
439
439
|
- **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
|
|
440
440
|
open for you to manage.
|
|
441
|
-
- **Never crashes the app.** A
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
441
|
+
- **Never crashes the app.** A broken destination degrades logging and nothing more. A sink that
|
|
442
|
+
delivered *part* of a batch counts what it lost (`.failed`, `.dropped_oversized`,
|
|
443
|
+
`.dropped_unadjudicated`, …) and returns, since retrying would re-deliver what already landed.
|
|
444
|
+
A sink that delivered **none** of it raises instead, so the worker's bounded retry engages and
|
|
445
|
+
`health().failed_batches` records the loss — there is nothing downstream to duplicate. Three
|
|
446
|
+
cases are excepted, each because a retry would be wrong rather than merely futile: an oversized
|
|
447
|
+
event (it can never fit), a response the sink could not adjudicate (it cannot prove nothing
|
|
448
|
+
landed), and an SQS sender fault (a byte-identical re-send can only fail again). Either way the
|
|
449
|
+
exception never reaches your code — inside a span the worker catches it, and on the orphan path
|
|
450
|
+
(`log_foundry.info(...)` outside any span, which emits synchronously) the emitter does.
|
|
447
451
|
|
|
448
452
|
#### Built-in, zero-dependency
|
|
449
453
|
|
|
@@ -466,7 +470,7 @@ to a plain callable.
|
|
|
466
470
|
|
|
467
471
|
| Sink | Import from | Configure |
|
|
468
472
|
|---|---|---|
|
|
469
|
-
| `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed
|
|
473
|
+
| `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed`, unless *every* child failed, which re-raises |
|
|
470
474
|
| `FilteringSink` | `log_foundry.sinks.filtering` | `FilteringSink(inner, *, predicate=None, min_level=None)` — forward only events passing `predicate` and/or at/above `min_level` |
|
|
471
475
|
| `TransformSink` | `log_foundry.sinks.transform` | `TransformSink(inner, fn)` — map each event through `fn` before forwarding; return `None` to drop one |
|
|
472
476
|
| `CallbackSink` | `log_foundry.sinks.callback` | `CallbackSink(fn, *, on_close=None)` — hand each batch to any callable |
|
|
@@ -659,8 +663,56 @@ Write-only inserts (querying is the downstream tool's job); each needs its own e
|
|
|
659
663
|
`PostgresSink` / `ClickHouseSink` default `create_table=False` (you own the schema and indexes); set
|
|
660
664
|
it `True` for an idempotent `CREATE TABLE IF NOT EXISTS` convenience.
|
|
661
665
|
|
|
662
|
-
Prefer a destination not listed here? Implement the
|
|
663
|
-
|
|
666
|
+
Prefer a destination not listed here? Implement the `Sink` protocol yourself, or wrap any callable
|
|
667
|
+
in `CallbackSink`.
|
|
668
|
+
|
|
669
|
+
#### Writing your own sink
|
|
670
|
+
|
|
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:
|
|
673
|
+
|
|
674
|
+
- **Raise when you delivered none of the batch**, after your own retries are spent. That is the
|
|
675
|
+
signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
|
|
676
|
+
a retry cannot duplicate anything: nothing landed downstream. Raise `SinkDeliveryError` (from
|
|
677
|
+
`log_foundry.sinks.base`) or any exception of your own — the contract is that *something*
|
|
678
|
+
propagates.
|
|
679
|
+
- **Do not raise when you delivered some of it.** The worker retries whole batches, so raising on a
|
|
680
|
+
partial success re-delivers the records that already arrived, and duplicates downstream are worse
|
|
681
|
+
than a counted loss.
|
|
682
|
+
|
|
683
|
+
A sink that absorbs a total failure and returns normally is a sink the worker believes: the retry
|
|
684
|
+
never engages, `failed_batches` stays at zero, and `flush()` returns `True` while every event is
|
|
685
|
+
lost.
|
|
686
|
+
|
|
687
|
+
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):
|
|
689
|
+
|
|
690
|
+
```python
|
|
691
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
692
|
+
|
|
693
|
+
class MySink:
|
|
694
|
+
def __init__(self) -> None:
|
|
695
|
+
self._dropped = self._failed = 0
|
|
696
|
+
|
|
697
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
698
|
+
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)
|
|
704
|
+
if batch and not delivered:
|
|
705
|
+
raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
|
|
706
|
+
|
|
707
|
+
def losses(self) -> SinkLosses:
|
|
708
|
+
return SinkLosses(dropped=self._dropped, failed=self._failed)
|
|
709
|
+
|
|
710
|
+
def close(self) -> None: ...
|
|
711
|
+
```
|
|
712
|
+
|
|
713
|
+
`losses()` is optional and probed by name, so a sink written before it existed keeps working and
|
|
714
|
+
simply contributes nothing to `health().sink`. `emit([])` must be a no-op: an empty batch has not
|
|
715
|
+
failed to deliver.
|
|
664
716
|
|
|
665
717
|
### Flushing and shutdown
|
|
666
718
|
|
|
@@ -676,17 +728,33 @@ returns a snapshot of the worker's counters:
|
|
|
676
728
|
|
|
677
729
|
```python
|
|
678
730
|
h = log_foundry.health()
|
|
679
|
-
if h.dropped or h.failed_batches or h.stopped_reason:
|
|
731
|
+
if h.dropped or h.failed_batches or h.stopped_reason or (h.sink and (h.sink.dropped or h.sink.failed)):
|
|
680
732
|
... # logs were silently lost — worth an alert
|
|
681
733
|
```
|
|
682
734
|
|
|
683
|
-
|
|
735
|
+
They tell you different things, and they want different responses:
|
|
684
736
|
|
|
685
737
|
| Field | Means | What to do |
|
|
686
738
|
|---|---|---|
|
|
687
739
|
| `dropped` | The queue filled — the destination is not keeping up. Delivery continues. | Tune `batch_size`/`flush_interval`, or scale the sink. |
|
|
688
740
|
| `failed_batches` | A sink stayed broken through the whole retry budget. Delivery continues. | Fix the destination. |
|
|
689
741
|
| `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
|
|
742
|
+
| `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. |
|
|
743
|
+
| `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
|
|
744
|
+
|
|
745
|
+
`h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
|
|
746
|
+
the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
|
|
747
|
+
different things: the worker's is backpressure at *its* queue, the sink's is an event that never
|
|
748
|
+
reached the wire. They are separate because the remedies do not overlap — and `sink.dropped` is
|
|
749
|
+
itself two causes, which is why the diagnostic line matters. Most sinks drop only what can never
|
|
750
|
+
fit; `KafkaSink` and `GooglePubSubSink` also count what their client refused outright, which may
|
|
751
|
+
be backpressure one layer further out than the worker's, or may be a misconfiguration. The stderr
|
|
752
|
+
line carries the exception type that distinguishes them.
|
|
753
|
+
|
|
754
|
+
`sink.failed` is an **upper bound** on loss, not a count of it. A sink that raises on total failure
|
|
755
|
+
counts the attempt *and* hands the batch back to the worker, whose retry may then deliver it — so a
|
|
756
|
+
transient outage leaves it non-zero with nothing actually lost. `failed_batches` is the record of a
|
|
757
|
+
batch given up on for good.
|
|
690
758
|
|
|
691
759
|
`stopped_reason` is a type name (e.g. `"SystemExit"`), never the exception's message — a sink's
|
|
692
760
|
error text can carry event data. It reads `None` for a healthy worker, for a process that has never
|
|
@@ -694,9 +762,10 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
|
|
|
694
762
|
thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
|
|
695
763
|
pointing at the wrong fix.
|
|
696
764
|
|
|
697
|
-
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and gained
|
|
698
|
-
fourth
|
|
699
|
-
`
|
|
765
|
+
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
766
|
+
fields over time — a fourth (`stopped_reason`) in `v0.7.0` and a fifth (`sink`) not yet in a tagged
|
|
767
|
+
release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
|
|
768
|
+
field keeps its position when a new one is appended, so attribute and index access stay stable.
|
|
700
769
|
|
|
701
770
|
`dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
|
|
702
771
|
abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
|
|
@@ -711,9 +780,23 @@ which one you want depends on whether the process is about to end:
|
|
|
711
780
|
import log_foundry as lf
|
|
712
781
|
|
|
713
782
|
lf.flush() # drain to the sink and keep going; returns True when everything landed
|
|
714
|
-
lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
|
|
783
|
+
lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
|
|
715
784
|
```
|
|
716
785
|
|
|
786
|
+
Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
|
|
787
|
+
returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
|
|
788
|
+
what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
|
|
789
|
+
either waits indefinitely, which is unsafe in any environment with an execution deadline.
|
|
790
|
+
|
|
791
|
+
**What a broken destination can cost you.** There is one drain thread, so a sink's backoff pauses
|
|
792
|
+
*all* log delivery, and it spans `shutdown()`. At the defaults (`max_retries=3`) that is 0.7 s of
|
|
793
|
+
backoff per batch for most sinks (per *message* for the socket-backed ones — ~70 s for a
|
|
794
|
+
100-message batch against a dead syslog host), and up to 90 s for an HTTP sink whose destination
|
|
795
|
+
is sending
|
|
796
|
+
`Retry-After` — clamped to `max_retry_after=30.0` per wait, which you can lower. Every wait is cut
|
|
797
|
+
short by a shutdown, and `shutdown()`'s own timeout bounds the total either way. Each sink's class
|
|
798
|
+
docstring states its own worst case.
|
|
799
|
+
|
|
717
800
|
| | `flush()` | `shutdown()` |
|
|
718
801
|
|---|---|---|
|
|
719
802
|
| Drains buffered events | yes | yes |
|
|
@@ -402,12 +402,16 @@ A few conventions hold across every sink below:
|
|
|
402
402
|
The tables show the destination-defining arguments only; sinks that retry also take `max_retries`.
|
|
403
403
|
- **Ownership.** A resource the sink opens itself is closed on `shutdown()`; an injected one is left
|
|
404
404
|
open for you to manage.
|
|
405
|
-
- **Never crashes the app.** A
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
405
|
+
- **Never crashes the app.** A broken destination degrades logging and nothing more. A sink that
|
|
406
|
+
delivered *part* of a batch counts what it lost (`.failed`, `.dropped_oversized`,
|
|
407
|
+
`.dropped_unadjudicated`, …) and returns, since retrying would re-deliver what already landed.
|
|
408
|
+
A sink that delivered **none** of it raises instead, so the worker's bounded retry engages and
|
|
409
|
+
`health().failed_batches` records the loss — there is nothing downstream to duplicate. Three
|
|
410
|
+
cases are excepted, each because a retry would be wrong rather than merely futile: an oversized
|
|
411
|
+
event (it can never fit), a response the sink could not adjudicate (it cannot prove nothing
|
|
412
|
+
landed), and an SQS sender fault (a byte-identical re-send can only fail again). Either way the
|
|
413
|
+
exception never reaches your code — inside a span the worker catches it, and on the orphan path
|
|
414
|
+
(`log_foundry.info(...)` outside any span, which emits synchronously) the emitter does.
|
|
411
415
|
|
|
412
416
|
#### Built-in, zero-dependency
|
|
413
417
|
|
|
@@ -430,7 +434,7 @@ to a plain callable.
|
|
|
430
434
|
|
|
431
435
|
| Sink | Import from | Configure |
|
|
432
436
|
|---|---|---|
|
|
433
|
-
| `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed
|
|
437
|
+
| `MultiSink` | `log_foundry.sinks.multi` | `MultiSink(*sinks)` — forward each batch to every child; a failing child is isolated and counted on `.failed`, unless *every* child failed, which re-raises |
|
|
434
438
|
| `FilteringSink` | `log_foundry.sinks.filtering` | `FilteringSink(inner, *, predicate=None, min_level=None)` — forward only events passing `predicate` and/or at/above `min_level` |
|
|
435
439
|
| `TransformSink` | `log_foundry.sinks.transform` | `TransformSink(inner, fn)` — map each event through `fn` before forwarding; return `None` to drop one |
|
|
436
440
|
| `CallbackSink` | `log_foundry.sinks.callback` | `CallbackSink(fn, *, on_close=None)` — hand each batch to any callable |
|
|
@@ -623,8 +627,56 @@ Write-only inserts (querying is the downstream tool's job); each needs its own e
|
|
|
623
627
|
`PostgresSink` / `ClickHouseSink` default `create_table=False` (you own the schema and indexes); set
|
|
624
628
|
it `True` for an idempotent `CREATE TABLE IF NOT EXISTS` convenience.
|
|
625
629
|
|
|
626
|
-
Prefer a destination not listed here? Implement the
|
|
627
|
-
|
|
630
|
+
Prefer a destination not listed here? Implement the `Sink` protocol yourself, or wrap any callable
|
|
631
|
+
in `CallbackSink`.
|
|
632
|
+
|
|
633
|
+
#### Writing your own sink
|
|
634
|
+
|
|
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:
|
|
637
|
+
|
|
638
|
+
- **Raise when you delivered none of the batch**, after your own retries are spent. That is the
|
|
639
|
+
signal the worker's bounded retry and `health().failed_batches` depend on, and the one case where
|
|
640
|
+
a retry cannot duplicate anything: nothing landed downstream. Raise `SinkDeliveryError` (from
|
|
641
|
+
`log_foundry.sinks.base`) or any exception of your own — the contract is that *something*
|
|
642
|
+
propagates.
|
|
643
|
+
- **Do not raise when you delivered some of it.** The worker retries whole batches, so raising on a
|
|
644
|
+
partial success re-delivers the records that already arrived, and duplicates downstream are worse
|
|
645
|
+
than a counted loss.
|
|
646
|
+
|
|
647
|
+
A sink that absorbs a total failure and returns normally is a sink the worker believes: the retry
|
|
648
|
+
never engages, `failed_batches` stays at zero, and `flush()` returns `True` while every event is
|
|
649
|
+
lost.
|
|
650
|
+
|
|
651
|
+
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):
|
|
653
|
+
|
|
654
|
+
```python
|
|
655
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
656
|
+
|
|
657
|
+
class MySink:
|
|
658
|
+
def __init__(self) -> None:
|
|
659
|
+
self._dropped = self._failed = 0
|
|
660
|
+
|
|
661
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
662
|
+
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)
|
|
668
|
+
if batch and not delivered:
|
|
669
|
+
raise SinkDeliveryError(f"MySink delivered none of {len(batch)} event(s)")
|
|
670
|
+
|
|
671
|
+
def losses(self) -> SinkLosses:
|
|
672
|
+
return SinkLosses(dropped=self._dropped, failed=self._failed)
|
|
673
|
+
|
|
674
|
+
def close(self) -> None: ...
|
|
675
|
+
```
|
|
676
|
+
|
|
677
|
+
`losses()` is optional and probed by name, so a sink written before it existed keeps working and
|
|
678
|
+
simply contributes nothing to `health().sink`. `emit([])` must be a no-op: an empty batch has not
|
|
679
|
+
failed to deliver.
|
|
628
680
|
|
|
629
681
|
### Flushing and shutdown
|
|
630
682
|
|
|
@@ -640,17 +692,33 @@ returns a snapshot of the worker's counters:
|
|
|
640
692
|
|
|
641
693
|
```python
|
|
642
694
|
h = log_foundry.health()
|
|
643
|
-
if h.dropped or h.failed_batches or h.stopped_reason:
|
|
695
|
+
if h.dropped or h.failed_batches or h.stopped_reason or (h.sink and (h.sink.dropped or h.sink.failed)):
|
|
644
696
|
... # logs were silently lost — worth an alert
|
|
645
697
|
```
|
|
646
698
|
|
|
647
|
-
|
|
699
|
+
They tell you different things, and they want different responses:
|
|
648
700
|
|
|
649
701
|
| Field | Means | What to do |
|
|
650
702
|
|---|---|---|
|
|
651
703
|
| `dropped` | The queue filled — the destination is not keeping up. Delivery continues. | Tune `batch_size`/`flush_interval`, or scale the sink. |
|
|
652
704
|
| `failed_batches` | A sink stayed broken through the whole retry budget. Delivery continues. | Fix the destination. |
|
|
653
705
|
| `stopped_reason` | The background thread **died** on that exception type. Nothing further will be delivered, ever. | Restart the process; investigate the named exception. |
|
|
706
|
+
| `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. |
|
|
707
|
+
| `sink.failed` | The sink attempted delivery and could not confirm it — abandoned requests, partially-failed batches, responses it could not adjudicate. | Fix the destination. |
|
|
708
|
+
|
|
709
|
+
`h.sink` is a `SinkLosses(dropped, failed)` or `None` — `None` when no worker exists yet, or when
|
|
710
|
+
the configured sink reports nothing (`losses()` is optional). Note the two `dropped` fields count
|
|
711
|
+
different things: the worker's is backpressure at *its* queue, the sink's is an event that never
|
|
712
|
+
reached the wire. They are separate because the remedies do not overlap — and `sink.dropped` is
|
|
713
|
+
itself two causes, which is why the diagnostic line matters. Most sinks drop only what can never
|
|
714
|
+
fit; `KafkaSink` and `GooglePubSubSink` also count what their client refused outright, which may
|
|
715
|
+
be backpressure one layer further out than the worker's, or may be a misconfiguration. The stderr
|
|
716
|
+
line carries the exception type that distinguishes them.
|
|
717
|
+
|
|
718
|
+
`sink.failed` is an **upper bound** on loss, not a count of it. A sink that raises on total failure
|
|
719
|
+
counts the attempt *and* hands the batch back to the worker, whose retry may then deliver it — so a
|
|
720
|
+
transient outage leaves it non-zero with nothing actually lost. `failed_batches` is the record of a
|
|
721
|
+
batch given up on for good.
|
|
654
722
|
|
|
655
723
|
`stopped_reason` is a type name (e.g. `"SystemExit"`), never the exception's message — a sink's
|
|
656
724
|
error text can carry event data. It reads `None` for a healthy worker, for a process that has never
|
|
@@ -658,9 +726,10 @@ logged, and after a clean `shutdown()`, so a plain truthiness check is safe. Wit
|
|
|
658
726
|
thread showed up only indirectly, as `dropped` climbing once the queue filled — the wrong signal,
|
|
659
727
|
pointing at the wrong fix.
|
|
660
728
|
|
|
661
|
-
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and gained
|
|
662
|
-
fourth
|
|
663
|
-
`
|
|
729
|
+
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
730
|
+
fields over time — a fourth (`stopped_reason`) in `v0.7.0` and a fifth (`sink`) not yet in a tagged
|
|
731
|
+
release — so unpacking it whole (`queued, dropped, failed = health()`) raises `ValueError`. Every
|
|
732
|
+
field keeps its position when a new one is appended, so attribute and index access stay stable.
|
|
664
733
|
|
|
665
734
|
`dropped` counts submissions discarded because the queue filled; `failed_batches` counts batches
|
|
666
735
|
abandoned after the retry budget was spent. Overflow also warns on stderr — on the first drop and
|
|
@@ -675,9 +744,23 @@ which one you want depends on whether the process is about to end:
|
|
|
675
744
|
import log_foundry as lf
|
|
676
745
|
|
|
677
746
|
lf.flush() # drain to the sink and keep going; returns True when everything landed
|
|
678
|
-
lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
|
|
747
|
+
lf.shutdown() # drain, close the sink, and stop for good; blocks until drained (30s cap)
|
|
679
748
|
```
|
|
680
749
|
|
|
750
|
+
Both are bounded, because both can be called somewhere with a deadline. `flush(timeout=5.0)`
|
|
751
|
+
returns `False` if the drain did not complete; `shutdown(timeout=30.0)` returns having stopped
|
|
752
|
+
what it could, and reports `health().stopped_reason == "ShutdownTimeout"`. Passing `None` to
|
|
753
|
+
either waits indefinitely, which is unsafe in any environment with an execution deadline.
|
|
754
|
+
|
|
755
|
+
**What a broken destination can cost you.** There is one drain thread, so a sink's backoff pauses
|
|
756
|
+
*all* log delivery, and it spans `shutdown()`. At the defaults (`max_retries=3`) that is 0.7 s of
|
|
757
|
+
backoff per batch for most sinks (per *message* for the socket-backed ones — ~70 s for a
|
|
758
|
+
100-message batch against a dead syslog host), and up to 90 s for an HTTP sink whose destination
|
|
759
|
+
is sending
|
|
760
|
+
`Retry-After` — clamped to `max_retry_after=30.0` per wait, which you can lower. Every wait is cut
|
|
761
|
+
short by a shutdown, and `shutdown()`'s own timeout bounds the total either way. Each sink's class
|
|
762
|
+
docstring states its own worst case.
|
|
763
|
+
|
|
681
764
|
| | `flush()` | `shutdown()` |
|
|
682
765
|
|---|---|---|
|
|
683
766
|
| Drains buffered events | yes | yes |
|
|
@@ -20,7 +20,8 @@ from log_foundry.context import (
|
|
|
20
20
|
reset_context,
|
|
21
21
|
)
|
|
22
22
|
from log_foundry.decorator import continue_trace, trace
|
|
23
|
-
from log_foundry.
|
|
23
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
24
|
+
from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health
|
|
24
25
|
|
|
25
26
|
try:
|
|
26
27
|
# Distribution name ("log-foundry") differs from the import name ("log_foundry").
|
|
@@ -59,17 +60,29 @@ def flush(timeout: float | None = 5.0) -> bool:
|
|
|
59
60
|
def health() -> Health:
|
|
60
61
|
"""Snapshot the background worker's delivery counters (SPEC-017 FR-005). Never raises.
|
|
61
62
|
|
|
62
|
-
Returns ``queued`` / ``dropped`` / ``failed_batches`` / ``stopped_reason``. A
|
|
63
|
-
``dropped`` means the queue filled and submissions were discarded to keep your code
|
|
63
|
+
Returns ``queued`` / ``dropped`` / ``failed_batches`` / ``stopped_reason`` / ``sink``. A
|
|
64
|
+
non-zero ``dropped`` means the queue filled and submissions were discarded to keep your code
|
|
64
65
|
non-blocking; a non-zero ``failed_batches`` means a sink stayed broken through the whole
|
|
65
66
|
retry budget. Both are losses the library absorbs on purpose, and this is how you notice
|
|
66
67
|
them. A non-``None`` ``stopped_reason`` is worse than either: the background thread died
|
|
67
68
|
on that exception type, so nothing further will be delivered at all (SPEC-019)::
|
|
68
69
|
|
|
69
70
|
h = log_foundry.health()
|
|
70
|
-
if h.dropped or h.failed_batches or h.stopped_reason
|
|
71
|
+
if h.dropped or h.failed_batches or h.stopped_reason or (
|
|
72
|
+
h.sink and (h.sink.dropped or h.sink.failed)
|
|
73
|
+
):
|
|
71
74
|
... # raise an alert; logs were silently lost
|
|
72
75
|
|
|
76
|
+
``sink`` is the configured sink's own :class:`~log_foundry.sinks.base.SinkLosses` — loss the
|
|
77
|
+
*sink* absorbed rather than the worker, which the worker's counters cannot see (SPEC-026).
|
|
78
|
+
It is ``None`` when no worker exists and when the sink reports nothing, since ``losses()`` is
|
|
79
|
+
optional. Its ``dropped`` is not the worker's: the worker's is backpressure at the queue, the
|
|
80
|
+
sink's is an event that never reached the wire — usually one too large to ever fit, and for the
|
|
81
|
+
sinks whose client owns a local buffer (Kafka, Pub/Sub) also what that buffer refused. The
|
|
82
|
+
stderr line names which. Its ``failed`` is an upper bound on loss, not a count of it: a sink
|
|
83
|
+
that raises on total failure counts the attempt *and* hands the batch back, and the worker's
|
|
84
|
+
retry may then deliver it.
|
|
85
|
+
|
|
73
86
|
A process that has never logged has no worker, and asking after its health does not create
|
|
74
87
|
one — the snapshot is simply zeroed. Valid after :func:`shutdown`, which leaves the final
|
|
75
88
|
counters readable.
|
|
@@ -79,7 +92,7 @@ def health() -> Health:
|
|
|
79
92
|
return _worker_health()
|
|
80
93
|
|
|
81
94
|
|
|
82
|
-
def shutdown() -> None:
|
|
95
|
+
def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
83
96
|
"""Flush buffered events and close the sink, blocking until drained. Idempotent.
|
|
84
97
|
|
|
85
98
|
Also registered via ``atexit``; call it explicitly before a fast process exit when you
|
|
@@ -88,14 +101,24 @@ def shutdown() -> None:
|
|
|
88
101
|
This is **terminal** — the worker does not come back. Do not call it per-invocation in a
|
|
89
102
|
serverless handler: the first invocation on a warm container would log and every later one
|
|
90
103
|
would silently log nothing. Use :func:`flush` there, which drains and keeps the worker.
|
|
104
|
+
|
|
105
|
+
``timeout`` bounds the wait for the background thread (SPEC-027 FR-004). ``None`` waits
|
|
106
|
+
indefinitely, which is what this did unconditionally before and is still available on
|
|
107
|
+
request — but it is unsafe anywhere with an execution deadline, and ``atexit`` is one such
|
|
108
|
+
place: a sink blocked in a network call would hold the process open. An expired shutdown
|
|
109
|
+
reports ``health().stopped_reason == "ShutdownTimeout"`` and leaves the sink **open**, since
|
|
110
|
+
the drain thread may still be inside ``emit``. Never raises.
|
|
91
111
|
"""
|
|
92
112
|
from log_foundry.decorator import _shutdown_worker
|
|
93
113
|
|
|
94
|
-
_shutdown_worker()
|
|
114
|
+
_shutdown_worker(timeout)
|
|
95
115
|
|
|
96
116
|
|
|
97
117
|
__all__ = [
|
|
118
|
+
"DEFAULT_SHUTDOWN_TIMEOUT",
|
|
98
119
|
"Health",
|
|
120
|
+
"SinkDeliveryError",
|
|
121
|
+
"SinkLosses",
|
|
99
122
|
"__version__",
|
|
100
123
|
"configure",
|
|
101
124
|
"continue_trace",
|
|
@@ -39,7 +39,7 @@ from log_foundry.ids import (
|
|
|
39
39
|
parse_traceparent,
|
|
40
40
|
)
|
|
41
41
|
from log_foundry.model import Span, backfill_baggage, end_event, start_event
|
|
42
|
-
from log_foundry.worker import Health, Worker
|
|
42
|
+
from log_foundry.worker import DEFAULT_SHUTDOWN_TIMEOUT, Health, Worker
|
|
43
43
|
|
|
44
44
|
if TYPE_CHECKING:
|
|
45
45
|
import contextvars
|
|
@@ -209,10 +209,15 @@ def _get_worker() -> Worker:
|
|
|
209
209
|
return _worker
|
|
210
210
|
|
|
211
211
|
|
|
212
|
-
def _shutdown_worker() -> None:
|
|
213
|
-
"""Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``.
|
|
212
|
+
def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
213
|
+
"""Drain + close the process worker if one was created (idempotent). Backs ``shutdown()``.
|
|
214
|
+
|
|
215
|
+
The ``atexit`` registration binds this function, so the exit path gets the bounded form and
|
|
216
|
+
its default (SPEC-027 FR-004) — an unbounded join in an ``atexit`` handler is a process that
|
|
217
|
+
will not exit.
|
|
218
|
+
"""
|
|
214
219
|
if _worker is not None:
|
|
215
|
-
_worker.shutdown()
|
|
220
|
+
_worker.shutdown(timeout)
|
|
216
221
|
|
|
217
222
|
|
|
218
223
|
def _flush_worker(timeout: float | None = 5.0) -> bool:
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"""Shared retry waiting for the sinks (SPEC-027).
|
|
2
|
+
|
|
3
|
+
Every sink that retries does so by sleeping the one thread that delivers anything. The worker
|
|
4
|
+
owns a single drain thread by design (arch §9), so a sink's backoff is not a local decision — it
|
|
5
|
+
is a global pause on log delivery, and it is held across ``shutdown()``, which joins that thread.
|
|
6
|
+
|
|
7
|
+
Two rules follow, and this module is where both are applied once rather than at fifteen call
|
|
8
|
+
sites:
|
|
9
|
+
|
|
10
|
+
* **A wait is interruptible.** Sinks wait on an ``Event`` rather than calling ``time.sleep``, so a
|
|
11
|
+
shutdown cuts an in-progress backoff short instead of holding the drain thread for its full
|
|
12
|
+
delay. The worker's own backoff already did this (``worker.py``'s ``_stop.wait``); the sinks had
|
|
13
|
+
no access to the signal.
|
|
14
|
+
* **A server-supplied delay is advice, not an instruction.** ``Retry-After`` arrives from the
|
|
15
|
+
destination and went straight to ``time.sleep`` with no ceiling and no sign check: a measured
|
|
16
|
+
``Retry-After: 8`` with the default ``max_retries=3`` blocked ``shutdown()`` for 22 seconds, a
|
|
17
|
+
header of ``86400`` would stall logging for a day, and a negative one makes ``time.sleep``
|
|
18
|
+
raise — absorbed inside a span, but reaching the caller on the orphan path.
|
|
19
|
+
|
|
20
|
+
This module imports nothing from its own package (``_diag``'s rule, for the same reason: it sits
|
|
21
|
+
below everything that might want it).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import math
|
|
27
|
+
import time
|
|
28
|
+
from typing import TYPE_CHECKING
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING:
|
|
31
|
+
import threading
|
|
32
|
+
|
|
33
|
+
__all__ = ["MAX_WAIT", "clamp_server_delay", "wait"]
|
|
34
|
+
|
|
35
|
+
MAX_WAIT = 86_400.0
|
|
36
|
+
"""Hard ceiling on any single wait, in seconds — a day.
|
|
37
|
+
|
|
38
|
+
Not a policy, a totality guard. ``time.sleep`` and ``Event.wait`` both raise ``OverflowError``
|
|
39
|
+
past the platform's ``time_t``, so ``wait(1e18)`` — reachable from a large ``max_retry_after``, or
|
|
40
|
+
from ``0.1 * 2**63`` on an absurd ``max_retries`` — would raise on the drain thread, which is the
|
|
41
|
+
one thing this module exists to prevent. Nothing legitimate waits longer than this, and a caller
|
|
42
|
+
who genuinely wants to is better served by not logging.
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def wait(delay: float, stop: threading.Event | None = None) -> None:
|
|
47
|
+
"""Wait ``delay`` seconds, returning early if ``stop`` is set (SPEC-027 FR-002).
|
|
48
|
+
|
|
49
|
+
Total. A non-positive or non-finite delay returns immediately rather than raising, so a
|
|
50
|
+
caller that computed one from arithmetic — or from a destination's header — cannot turn a
|
|
51
|
+
backoff into an exception on the drain thread.
|
|
52
|
+
|
|
53
|
+
``stop`` is the worker's shutdown event when the sink was given one, and ``None`` for a sink
|
|
54
|
+
used standalone, which then waits exactly as it did before this spec. ``Event.wait`` is used
|
|
55
|
+
rather than ``time.sleep`` in both cases: with no event there is nothing to wait on, so the
|
|
56
|
+
``None`` path falls back to ``time.sleep``.
|
|
57
|
+
|
|
58
|
+
A finite delay larger than :data:`MAX_WAIT` is capped rather than passed through: both
|
|
59
|
+
``time.sleep`` and ``Event.wait`` raise ``OverflowError`` past the platform's ``time_t``, and
|
|
60
|
+
"total" has to mean total.
|
|
61
|
+
|
|
62
|
+
It does **not** abort an in-flight network call — only the pause between attempts. Cancelling
|
|
63
|
+
a socket mid-write is not something this library attempts.
|
|
64
|
+
"""
|
|
65
|
+
if not (delay > 0) or math.isinf(delay):
|
|
66
|
+
return
|
|
67
|
+
delay = min(delay, MAX_WAIT)
|
|
68
|
+
if stop is None:
|
|
69
|
+
time.sleep(delay)
|
|
70
|
+
return
|
|
71
|
+
stop.wait(delay)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def clamp_server_delay(value: float | None, ceiling: float) -> float | None:
|
|
75
|
+
"""Bound a server-supplied delay; ``None`` means "fall back to exponential backoff".
|
|
76
|
+
|
|
77
|
+
``None`` in and ``None`` out is the ordinary case: no ``Retry-After``, or one in HTTP-date
|
|
78
|
+
form that the parser declined. Everything else is validated against what a *delay* can be —
|
|
79
|
+
finite, positive, and no longer than the caller's ceiling.
|
|
80
|
+
|
|
81
|
+
Zero is rejected rather than honoured as "retry now". A destination that is rate-limiting has
|
|
82
|
+
asked us to slow down, and a header saying "wait zero seconds" is far more likely a broken or
|
|
83
|
+
truncated value than a real instruction to hammer it; the exponential backoff is the safer
|
|
84
|
+
reading. ``NaN`` falls out of the same comparison that rejects a negative: every comparison
|
|
85
|
+
against ``NaN`` is ``False``, which is why the test is written ``not (value > 0)`` rather than
|
|
86
|
+
``value <= 0`` — the latter reads ``False`` for ``NaN``, letting it through to ``min()``,
|
|
87
|
+
which returns it.
|
|
88
|
+
|
|
89
|
+
The ceiling is the caller's, not a constant: a platform that legitimately asks for a
|
|
90
|
+
two-minute pause should be allowed one, and a caller with an execution deadline should be able
|
|
91
|
+
to lower it below the default.
|
|
92
|
+
"""
|
|
93
|
+
if value is None or not (value > 0) or math.isinf(value):
|
|
94
|
+
return None
|
|
95
|
+
if not (ceiling > 0):
|
|
96
|
+
# An unusable ceiling is a misconfiguration, not an instruction to stop waiting. Left
|
|
97
|
+
# unchecked, ``min(value, 0)`` returns ``0.0`` — which is *not* ``None``, so the caller
|
|
98
|
+
# reads it as an honoured delay and retries with no backoff at all, and ``min(value,
|
|
99
|
+
# nan)`` returns ``value``, so the ceiling silently vanishes and the header is honoured
|
|
100
|
+
# in full. Both defeat this function in the direction it exists to prevent. Falling back
|
|
101
|
+
# to the sink's own backoff is the same answer a missing header gets.
|
|
102
|
+
return None
|
|
103
|
+
return min(value, ceiling)
|
|
@@ -12,9 +12,13 @@ substitute a fake socket without any network access.
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
14
|
import socket
|
|
15
|
-
import
|
|
15
|
+
from typing import TYPE_CHECKING
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
import threading
|
|
16
19
|
|
|
17
20
|
from log_foundry import _diag
|
|
21
|
+
from log_foundry.sinks._retry import wait
|
|
18
22
|
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
19
23
|
|
|
20
24
|
__all__ = ["SocketTransport"]
|
|
@@ -35,6 +39,11 @@ def _make_udp() -> socket.socket:
|
|
|
35
39
|
class SocketTransport:
|
|
36
40
|
"""Send pre-framed messages over a TCP or UDP socket, reconnecting on error within a bound.
|
|
37
41
|
|
|
42
|
+
**Worst-case delay** (SPEC-027 FR-005): ``max_retries`` waits of ``0.1 * 2**n`` *per
|
|
43
|
+
message* — 0.7 s per message at the default 3, so a 100-message batch against a dead
|
|
44
|
+
destination is ~70 s of backoff on the single drain thread. The wait is interruptible, so
|
|
45
|
+
``shutdown()`` cuts it short.
|
|
46
|
+
|
|
38
47
|
Attributes:
|
|
39
48
|
failed: Messages abandoned past the reconnect-retry bound.
|
|
40
49
|
"""
|
|
@@ -59,6 +68,8 @@ class SocketTransport:
|
|
|
59
68
|
self._max_retries = max(max_retries, 0)
|
|
60
69
|
self._sock: socket.socket | None = None
|
|
61
70
|
self.failed = 0
|
|
71
|
+
# Set by the worker through the owning sink (SPEC-027 FR-002).
|
|
72
|
+
self.stop_signal: threading.Event | None = None
|
|
62
73
|
|
|
63
74
|
def send_all(self, messages: list[bytes]) -> None:
|
|
64
75
|
"""Send each pre-framed message, reconnecting on error (FR-005, FR-006).
|
|
@@ -102,7 +113,7 @@ class SocketTransport:
|
|
|
102
113
|
except OSError as err:
|
|
103
114
|
self._reset() # force a fresh connection on the next attempt
|
|
104
115
|
if attempt < self._max_retries:
|
|
105
|
-
|
|
116
|
+
wait(_BACKOFF_BASE * (2**attempt), self.stop_signal)
|
|
106
117
|
continue
|
|
107
118
|
self.failed += 1
|
|
108
119
|
# Guarded now (SPEC-029 FR-003): this runs on the worker thread, and the bare
|
|
@@ -41,10 +41,12 @@ class SinkDeliveryError(Exception):
|
|
|
41
41
|
class SinkLosses(NamedTuple):
|
|
42
42
|
"""What a sink discarded or could not confirm, cumulative for its lifetime (SPEC-026 FR-002).
|
|
43
43
|
|
|
44
|
-
Two fields rather than one because the remedies differ
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
44
|
+
Two fields rather than one because the remedies differ. ``dropped`` is an event the sink
|
|
45
|
+
discarded *before* attempting delivery — usually one the destination could never have accepted
|
|
46
|
+
as built (an oversized record), so the fix is upstream in what the application logs; for a sink
|
|
47
|
+
whose client owns a local buffer it also covers what that buffer refused, which is
|
|
48
|
+
backpressure. The stderr line names which. ``failed`` means delivery was attempted and the
|
|
49
|
+
destination did not confirm it, so the fix is the destination or the network.
|
|
48
50
|
|
|
49
51
|
``failed`` is an **upper bound** on loss, not a count of it. A sink that also raises on total
|
|
50
52
|
failure counts the attempt here *and* hands the batch back to the worker, whose retry may
|