log-foundry 0.10.2.dev17__tar.gz → 0.10.2.dev18__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.dev17 → log_foundry-0.10.2.dev18}/PKG-INFO +1 -1
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/clickhouse.py +24 -7
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/eventhubs.py +42 -11
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/firehose.py +61 -9
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/kafka.py +36 -2
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/kinesis.py +60 -9
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/mongodb.py +30 -4
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/nats.py +16 -1
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/postgres.py +17 -3
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/pubsub.py +31 -2
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/rabbitmq.py +24 -6
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/redis.py +17 -3
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sns.py +30 -7
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sqs.py +65 -7
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/LICENSE +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/README.md +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/util.py +0 -0
- {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/worker.py +0 -0
|
@@ -14,6 +14,7 @@ from typing import Any
|
|
|
14
14
|
|
|
15
15
|
from log_foundry import _diag
|
|
16
16
|
from log_foundry.sinks._chunk import chunk_list, valid_identifier
|
|
17
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
17
18
|
|
|
18
19
|
__all__ = ["ClickHouseSink"]
|
|
19
20
|
|
|
@@ -54,7 +55,9 @@ class ClickHouseSink:
|
|
|
54
55
|
) -> None:
|
|
55
56
|
self._table = valid_identifier(table)
|
|
56
57
|
self._chunk_size = chunk_size
|
|
57
|
-
|
|
58
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
59
|
+
# from ``_insert`` having attempted nothing, and reported success.
|
|
60
|
+
self.max_retries = max(max_retries, 0)
|
|
58
61
|
self.failed = 0
|
|
59
62
|
self._closed = False
|
|
60
63
|
self._owns_client = client is None
|
|
@@ -66,13 +69,25 @@ class ClickHouseSink:
|
|
|
66
69
|
if create_table:
|
|
67
70
|
self._ensure_schema()
|
|
68
71
|
|
|
72
|
+
def losses(self) -> SinkLosses:
|
|
73
|
+
"""Rows in a chunk abandoned past the retry bound (SPEC-026 FR-002). Never raises."""
|
|
74
|
+
return SinkLosses(dropped=0, failed=self.failed)
|
|
75
|
+
|
|
69
76
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
70
|
-
"""Insert each chunk as one columnar ``insert`` call, retrying on failure (FR-002).
|
|
77
|
+
"""Insert each chunk as one columnar ``insert`` call, retrying on failure (FR-002).
|
|
78
|
+
|
|
79
|
+
Raises when every chunk failed (SPEC-026 FR-001) — which is the whole batch for any
|
|
80
|
+
batch that fits one chunk, the ordinary case. A partially-inserted batch does not raise:
|
|
81
|
+
the chunks that landed are committed, and the worker's retry would duplicate them.
|
|
82
|
+
"""
|
|
71
83
|
if not batch:
|
|
72
84
|
return
|
|
85
|
+
chunks = inserted = 0
|
|
73
86
|
for chunk in chunk_list(batch, self._chunk_size):
|
|
74
|
-
|
|
75
|
-
self._insert(
|
|
87
|
+
chunks += 1
|
|
88
|
+
inserted += self._insert([self._row(event) for event in chunk])
|
|
89
|
+
if chunks and not inserted:
|
|
90
|
+
raise SinkDeliveryError(f"ClickHouseSink inserted none of {chunks} chunk(s)")
|
|
76
91
|
|
|
77
92
|
def close(self) -> None:
|
|
78
93
|
"""Close the client only if the sink owns it; idempotent (FR-005)."""
|
|
@@ -87,11 +102,12 @@ class ClickHouseSink:
|
|
|
87
102
|
def _row(self, event: dict[str, object]) -> list[object]:
|
|
88
103
|
return [*(event.get(col) for col in _COLUMNS), json.dumps(event)]
|
|
89
104
|
|
|
90
|
-
def _insert(self, rows: list[list[object]]) ->
|
|
105
|
+
def _insert(self, rows: list[list[object]]) -> int:
|
|
106
|
+
"""Insert one chunk within the retry bound; ``1`` if it landed, ``0`` once abandoned."""
|
|
91
107
|
for attempt in range(self.max_retries + 1):
|
|
92
108
|
try:
|
|
93
109
|
self.client.insert(self._table, data=rows, column_names=_COLUMN_NAMES)
|
|
94
|
-
return
|
|
110
|
+
return 1
|
|
95
111
|
except Exception as err: # isolation boundary: never crash the worker (FR-006)
|
|
96
112
|
if attempt < self.max_retries:
|
|
97
113
|
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
@@ -102,7 +118,8 @@ class ClickHouseSink:
|
|
|
102
118
|
len(rows),
|
|
103
119
|
f"ClickHouseSink, {self.max_retries + 1} attempts, {type(err).__name__}",
|
|
104
120
|
)
|
|
105
|
-
return
|
|
121
|
+
return 0
|
|
122
|
+
return 0 # unreachable: the loop returns on every path (mypy needs the exit)
|
|
106
123
|
|
|
107
124
|
def _ensure_schema(self) -> None:
|
|
108
125
|
columns = ", ".join(f"{col} {_COLUMN_TYPES[col]}" for col in _COLUMNS)
|
|
@@ -14,6 +14,7 @@ import time
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from log_foundry import _diag
|
|
17
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
17
18
|
|
|
18
19
|
__all__ = ["AzureEventHubsSink"]
|
|
19
20
|
|
|
@@ -44,27 +45,52 @@ class AzureEventHubsSink:
|
|
|
44
45
|
connection_str, eventhub_name=eventhub
|
|
45
46
|
)
|
|
46
47
|
self.producer = producer
|
|
47
|
-
|
|
48
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
49
|
+
# from ``_send`` having attempted nothing, and reported success.
|
|
50
|
+
self.max_retries = max(max_retries, 0)
|
|
48
51
|
self.failed = 0
|
|
49
52
|
self.dropped_oversized = 0
|
|
50
53
|
|
|
54
|
+
def losses(self) -> SinkLosses:
|
|
55
|
+
"""Oversized drops and events in a batch abandoned past the retry bound (FR-002)."""
|
|
56
|
+
return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
|
|
57
|
+
|
|
51
58
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
52
|
-
"""Pack events into ≤ 1 MB EventDataBatches and send each; drop oversized events (FR-009).
|
|
59
|
+
"""Pack events into ≤ 1 MB EventDataBatches and send each; drop oversized events (FR-009).
|
|
60
|
+
|
|
61
|
+
Raises when every ``EventDataBatch`` failed to send and at least one was attempted
|
|
62
|
+
(SPEC-026 FR-001). An event dropped for being too large is not a send failure — it can
|
|
63
|
+
never fit, so a batch of nothing but oversized events has nothing to retry and does not
|
|
64
|
+
raise; it is reported through ``losses().dropped`` instead.
|
|
65
|
+
"""
|
|
53
66
|
if not batch:
|
|
54
67
|
return
|
|
55
68
|
event_data_cls = _event_data_cls()
|
|
56
69
|
current = self.producer.create_batch()
|
|
70
|
+
attempted = delivered = 0
|
|
57
71
|
for event in batch:
|
|
58
72
|
data = event_data_cls(json.dumps(event).encode("utf-8"))
|
|
59
73
|
if _try_add(current, data):
|
|
60
74
|
continue
|
|
61
|
-
# current batch is full: send it and start a fresh one for this event.
|
|
62
|
-
|
|
75
|
+
# current batch is full: send it and start a fresh one for this event. Guarded on
|
|
76
|
+
# emptiness because ``_try_add`` also fails against a *fresh* batch when the event
|
|
77
|
+
# is oversized, and the SDK short-circuits ``send_batch`` on an empty batch and
|
|
78
|
+
# returns — a phantom success that counted as delivery and suppressed the raise for
|
|
79
|
+
# everything else in the emit.
|
|
80
|
+
if len(current) > 0:
|
|
81
|
+
attempted += 1
|
|
82
|
+
delivered += self._send(current)
|
|
63
83
|
current = self.producer.create_batch()
|
|
64
84
|
if not _try_add(current, data):
|
|
65
85
|
self.dropped_oversized += 1
|
|
66
86
|
_diag.lost("event", 1, "AzureEventHubsSink, too large for an empty 1 MB batch")
|
|
67
|
-
|
|
87
|
+
if len(current) > 0:
|
|
88
|
+
attempted += 1
|
|
89
|
+
delivered += self._send(current)
|
|
90
|
+
if attempted and not delivered:
|
|
91
|
+
raise SinkDeliveryError(
|
|
92
|
+
f"AzureEventHubsSink sent none of {attempted} EventDataBatch(es)"
|
|
93
|
+
)
|
|
68
94
|
|
|
69
95
|
def close(self) -> None:
|
|
70
96
|
"""Close the producer (FR-009)."""
|
|
@@ -72,14 +98,18 @@ class AzureEventHubsSink:
|
|
|
72
98
|
|
|
73
99
|
# -- internals ----------------------------------------------------------------------
|
|
74
100
|
|
|
75
|
-
def _send(self, event_batch: Any) ->
|
|
76
|
-
"""Send one EventDataBatch
|
|
77
|
-
|
|
78
|
-
|
|
101
|
+
def _send(self, event_batch: Any) -> int:
|
|
102
|
+
"""Send one EventDataBatch, retrying failures; ``1`` if it landed (FR-009, FR-011).
|
|
103
|
+
|
|
104
|
+
Callers only reach this with a non-empty batch — both call sites check — and count the
|
|
105
|
+
result: the "did anything land" question in ``emit`` cannot be answered by a method that
|
|
106
|
+
returns nothing. An empty one must never be sent: the SDK returns immediately without
|
|
107
|
+
contacting the hub, which would score as a delivery nobody made.
|
|
108
|
+
"""
|
|
79
109
|
for attempt in range(self.max_retries + 1):
|
|
80
110
|
try:
|
|
81
111
|
self.producer.send_batch(event_batch)
|
|
82
|
-
return
|
|
112
|
+
return 1
|
|
83
113
|
except Exception as err: # isolation boundary: never crash the worker (FR-011)
|
|
84
114
|
if attempt < self.max_retries:
|
|
85
115
|
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
@@ -91,7 +121,8 @@ class AzureEventHubsSink:
|
|
|
91
121
|
f"AzureEventHubsSink, one batch, {self.max_retries + 1} attempts, "
|
|
92
122
|
f"{type(err).__name__}",
|
|
93
123
|
)
|
|
94
|
-
return
|
|
124
|
+
return 0
|
|
125
|
+
return 0 # unreachable: the loop returns on every path (mypy needs the exit)
|
|
95
126
|
|
|
96
127
|
|
|
97
128
|
def _try_add(event_batch: Any, data: Any) -> bool:
|
|
@@ -14,6 +14,7 @@ from typing import Any
|
|
|
14
14
|
from log_foundry import _diag
|
|
15
15
|
from log_foundry.sinks._batch import adjudicate_positional, usable_results
|
|
16
16
|
from log_foundry.sinks._chunk import chunk_items
|
|
17
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
17
18
|
|
|
18
19
|
__all__ = ["FirehoseSink"]
|
|
19
20
|
|
|
@@ -41,21 +42,62 @@ class FirehoseSink:
|
|
|
41
42
|
client = boto3.client("firehose")
|
|
42
43
|
self.delivery_stream = delivery_stream
|
|
43
44
|
self.client = client
|
|
44
|
-
|
|
45
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
46
|
+
# from ``_send`` having sent nothing, and reported success.
|
|
47
|
+
self.max_retries = max(max_retries, 0)
|
|
45
48
|
self.failed = 0
|
|
46
49
|
self.dropped_oversized = 0
|
|
47
50
|
self.dropped_unadjudicated = 0
|
|
48
51
|
|
|
49
52
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
50
|
-
"""Re-chunk to put_record_batch limits and send each chunk, retrying failures (FR-004).
|
|
53
|
+
"""Re-chunk to put_record_batch limits and send each chunk, retrying failures (FR-004).
|
|
54
|
+
|
|
55
|
+
Raises when every chunk failed and at least one was sent (SPEC-026 FR-001). Records
|
|
56
|
+
dropped before sending — too large to ever fit — are not a send failure and do not make
|
|
57
|
+
a batch of nothing but oversized records raise; they can never be retried into
|
|
58
|
+
existence, and are reported through ``losses().dropped``.
|
|
59
|
+
|
|
60
|
+
An unadjudicable chunk is *unknown*, not *nothing*, and suppresses the raise. SPEC-018
|
|
61
|
+
settled that such a chunk is abandoned rather than re-sent — the API reported a failure
|
|
62
|
+
count, so some of it landed, and the worker's retry would duplicate that downstream
|
|
63
|
+
forever. Raising on it would be exactly the re-send SPEC-018 refuses.
|
|
64
|
+
|
|
65
|
+
That suppression is batch-wide, unlike ``SQSSink``'s. A sender fault is a rejection —
|
|
66
|
+
the entries provably did not land, so re-sending them is futile rather than harmful —
|
|
67
|
+
while "unadjudicable" means this sink *cannot tell* whether they landed. Once any chunk
|
|
68
|
+
is in that state the emit can no longer prove nothing was delivered, so a raise would
|
|
69
|
+
risk duplicating it, and the events in a plainly-failed sibling chunk stay a counted
|
|
70
|
+
loss rather than a duplicated delivery. SPEC-018 chose that trade once already.
|
|
71
|
+
"""
|
|
51
72
|
records = self._records(batch)
|
|
73
|
+
chunks = delivered = 0
|
|
74
|
+
unknown = False
|
|
52
75
|
for chunk in chunk_items(
|
|
53
76
|
records,
|
|
54
77
|
max_count=self.MAX_RECORDS,
|
|
55
78
|
max_bytes=self.MAX_REQUEST_BYTES,
|
|
56
79
|
size_of=lambda record: len(record["Data"]),
|
|
57
80
|
):
|
|
58
|
-
|
|
81
|
+
chunks += 1
|
|
82
|
+
outcome = self._send(chunk)
|
|
83
|
+
if outcome is None:
|
|
84
|
+
unknown = True
|
|
85
|
+
else:
|
|
86
|
+
delivered += outcome
|
|
87
|
+
if chunks and not delivered and not unknown:
|
|
88
|
+
raise SinkDeliveryError(f"FirehoseSink delivered none of {chunks} chunk(s)")
|
|
89
|
+
|
|
90
|
+
def losses(self) -> SinkLosses:
|
|
91
|
+
"""Oversized drops, abandoned records and unadjudicable chunks (FR-002). Never raises.
|
|
92
|
+
|
|
93
|
+
``failed`` sums ``failed`` and ``dropped_unadjudicated``: both are records the stream
|
|
94
|
+
never confirmed. They stay apart on the instance, because "the stream said these failed"
|
|
95
|
+
and "the response did not describe them" have different remedies (SPEC-018).
|
|
96
|
+
"""
|
|
97
|
+
return SinkLosses(
|
|
98
|
+
dropped=self.dropped_oversized,
|
|
99
|
+
failed=self.failed + self.dropped_unadjudicated,
|
|
100
|
+
)
|
|
59
101
|
|
|
60
102
|
def close(self) -> None:
|
|
61
103
|
"""No-op: the sink buffers nothing internally (FR-001)."""
|
|
@@ -79,14 +121,23 @@ class FirehoseSink:
|
|
|
79
121
|
records.append({"Data": data})
|
|
80
122
|
return records
|
|
81
123
|
|
|
82
|
-
|
|
83
|
-
|
|
124
|
+
|
|
125
|
+
def _send(self, records: list[dict[str, Any]]) -> int | None:
|
|
126
|
+
"""Send one chunk, retrying only the entries the response flags as failed (FR-004).
|
|
127
|
+
|
|
128
|
+
Returns how many records the stream accepted, so ``emit`` can tell "nothing landed"
|
|
129
|
+
from a partial success — or ``None`` when the response could not be adjudicated, which
|
|
130
|
+
is neither. "The stream did not say" must not be read as "the stream took nothing":
|
|
131
|
+
that reading would make ``emit`` raise, and the worker's retry would re-send a chunk
|
|
132
|
+
SPEC-018 settled must never be re-sent.
|
|
133
|
+
"""
|
|
134
|
+
sent = len(records)
|
|
84
135
|
for attempt in range(self.max_retries + 1):
|
|
85
136
|
response = self.client.put_record_batch(
|
|
86
137
|
DeliveryStreamName=self.delivery_stream, Records=records
|
|
87
138
|
)
|
|
88
139
|
if not response.get("FailedPutCount"):
|
|
89
|
-
return
|
|
140
|
+
return sent
|
|
90
141
|
results = usable_results(response.get("RequestResponses"))
|
|
91
142
|
verdict = adjudicate_positional(records, results)
|
|
92
143
|
if verdict.unadjudicated:
|
|
@@ -98,10 +149,10 @@ class FirehoseSink:
|
|
|
98
149
|
f"({len(records)} record(s) sent, {len(results)} result(s) returned); "
|
|
99
150
|
f"abandoned, not retried",
|
|
100
151
|
)
|
|
101
|
-
return
|
|
152
|
+
return None
|
|
102
153
|
records = verdict.retry
|
|
103
154
|
if not records:
|
|
104
|
-
return
|
|
155
|
+
return sent
|
|
105
156
|
if attempt >= self.max_retries:
|
|
106
157
|
self.failed += len(records)
|
|
107
158
|
_diag.lost(
|
|
@@ -109,4 +160,5 @@ class FirehoseSink:
|
|
|
109
160
|
len(records),
|
|
110
161
|
f"FirehoseSink, still failing after {self.max_retries + 1} attempts; abandoned",
|
|
111
162
|
)
|
|
112
|
-
return
|
|
163
|
+
return sent - len(records)
|
|
164
|
+
return 0 # unreachable: the loop returns on every path (mypy needs the exit)
|
|
@@ -13,6 +13,7 @@ import json
|
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
15
|
from log_foundry import _diag
|
|
16
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
16
17
|
|
|
17
18
|
__all__ = ["KafkaSink"]
|
|
18
19
|
|
|
@@ -22,6 +23,8 @@ class KafkaSink:
|
|
|
22
23
|
|
|
23
24
|
Attributes:
|
|
24
25
|
failed: Messages whose delivery callback reported an error.
|
|
26
|
+
rejected: Messages ``produce()`` itself refused — a full local queue, a serialization
|
|
27
|
+
fault — which never reached the producer's batch at all.
|
|
25
28
|
"""
|
|
26
29
|
|
|
27
30
|
def __init__(
|
|
@@ -44,14 +47,45 @@ class KafkaSink:
|
|
|
44
47
|
self.producer = producer
|
|
45
48
|
self.key_field = key_field
|
|
46
49
|
self.failed = 0
|
|
50
|
+
self.rejected = 0
|
|
51
|
+
|
|
52
|
+
def losses(self) -> SinkLosses:
|
|
53
|
+
"""Refused and undelivered messages (SPEC-026 FR-002). Never raises.
|
|
54
|
+
|
|
55
|
+
``rejected`` is ``dropped``: ``produce()`` refused it, so nothing ever left the process.
|
|
56
|
+
``failed`` is the delivery callback's verdict on a message the producer *did* accept.
|
|
57
|
+
|
|
58
|
+
A callback failure never makes ``emit`` raise, and the reason is ownership rather than
|
|
59
|
+
timing — ``poll(0)`` runs inside ``emit``, so a callback for a message produced earlier
|
|
60
|
+
in the same batch can and does fire before it returns. Once the producer has accepted a
|
|
61
|
+
message it owns delivery, including its own retries; re-producing it from here would
|
|
62
|
+
duplicate whatever the producer eventually lands. So the raise decision reads only what
|
|
63
|
+
``produce()`` refused.
|
|
64
|
+
"""
|
|
65
|
+
return SinkLosses(dropped=self.rejected, failed=self.failed)
|
|
47
66
|
|
|
48
67
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
49
|
-
"""Produce one message per event; serve delivery callbacks without blocking (FR-002).
|
|
68
|
+
"""Produce one message per event; serve delivery callbacks without blocking (FR-002).
|
|
69
|
+
|
|
70
|
+
``produce()`` is a local hand-off, so what it refuses is the only failure this call can
|
|
71
|
+
observe. When it refused *every* message the batch reached nothing, which is the total
|
|
72
|
+
failure the worker's retry exists for (SPEC-026 FR-001); a partial refusal is counted and
|
|
73
|
+
left alone, since the messages that were accepted are already on their way.
|
|
74
|
+
"""
|
|
75
|
+
accepted = 0
|
|
50
76
|
for event in batch:
|
|
51
77
|
body = json.dumps(event).encode("utf-8")
|
|
52
78
|
key = self._key(event)
|
|
53
|
-
|
|
79
|
+
try:
|
|
80
|
+
self.producer.produce(self.topic, value=body, key=key, callback=self._on_delivery)
|
|
81
|
+
except Exception as err: # BufferError on a full local queue, and anything the
|
|
82
|
+
self.rejected += 1 # driver raises for an unproducible message
|
|
83
|
+
_diag.lost("message", 1, f"KafkaSink produce, {type(err).__name__}")
|
|
84
|
+
continue
|
|
85
|
+
accepted += 1
|
|
54
86
|
self.producer.poll(0) # serve queued delivery callbacks, non-blocking
|
|
87
|
+
if batch and not accepted:
|
|
88
|
+
raise SinkDeliveryError(f"KafkaSink produced none of {len(batch)} message(s)")
|
|
55
89
|
|
|
56
90
|
def close(self) -> None:
|
|
57
91
|
"""Flush the producer so buffered messages are delivered before exit (FR-002)."""
|
|
@@ -15,6 +15,7 @@ from typing import Any
|
|
|
15
15
|
from log_foundry import _diag
|
|
16
16
|
from log_foundry.sinks._batch import adjudicate_positional, usable_results
|
|
17
17
|
from log_foundry.sinks._chunk import chunk_items
|
|
18
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
18
19
|
|
|
19
20
|
__all__ = ["KinesisSink"]
|
|
20
21
|
|
|
@@ -49,21 +50,62 @@ class KinesisSink:
|
|
|
49
50
|
self.stream_name = stream_name
|
|
50
51
|
self.client = client
|
|
51
52
|
self.partition_key_field = partition_key_field
|
|
52
|
-
|
|
53
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
54
|
+
# from ``_send`` having sent nothing, and reported success.
|
|
55
|
+
self.max_retries = max(max_retries, 0)
|
|
53
56
|
self.failed = 0
|
|
54
57
|
self.dropped_oversized = 0
|
|
55
58
|
self.dropped_unadjudicated = 0
|
|
56
59
|
|
|
57
60
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
58
|
-
"""Re-chunk to put_records limits and send each chunk, retrying failures (FR-003).
|
|
61
|
+
"""Re-chunk to put_records limits and send each chunk, retrying failures (FR-003).
|
|
62
|
+
|
|
63
|
+
Raises when every chunk failed and at least one was sent (SPEC-026 FR-001). Records
|
|
64
|
+
dropped before sending — too large to ever fit — are not a send failure and do not make
|
|
65
|
+
a batch of nothing but oversized records raise; they can never be retried into
|
|
66
|
+
existence, and are reported through ``losses().dropped``.
|
|
67
|
+
|
|
68
|
+
An unadjudicable chunk is *unknown*, not *nothing*, and suppresses the raise. SPEC-018
|
|
69
|
+
settled that such a chunk is abandoned rather than re-sent — the API reported a failure
|
|
70
|
+
count, so some of it landed, and the worker's retry would duplicate that downstream
|
|
71
|
+
forever. Raising on it would be exactly the re-send SPEC-018 refuses.
|
|
72
|
+
|
|
73
|
+
That suppression is batch-wide, unlike ``SQSSink``'s. A sender fault is a rejection —
|
|
74
|
+
the entries provably did not land, so re-sending them is futile rather than harmful —
|
|
75
|
+
while "unadjudicable" means this sink *cannot tell* whether they landed. Once any chunk
|
|
76
|
+
is in that state the emit can no longer prove nothing was delivered, so a raise would
|
|
77
|
+
risk duplicating it, and the events in a plainly-failed sibling chunk stay a counted
|
|
78
|
+
loss rather than a duplicated delivery. SPEC-018 chose that trade once already.
|
|
79
|
+
"""
|
|
59
80
|
records = self._records(batch)
|
|
81
|
+
chunks = delivered = 0
|
|
82
|
+
unknown = False
|
|
60
83
|
for chunk in chunk_items(
|
|
61
84
|
records,
|
|
62
85
|
max_count=self.MAX_RECORDS,
|
|
63
86
|
max_bytes=self.MAX_REQUEST_BYTES,
|
|
64
87
|
size_of=lambda record: len(record["Data"]),
|
|
65
88
|
):
|
|
66
|
-
|
|
89
|
+
chunks += 1
|
|
90
|
+
outcome = self._send(chunk)
|
|
91
|
+
if outcome is None:
|
|
92
|
+
unknown = True
|
|
93
|
+
else:
|
|
94
|
+
delivered += outcome
|
|
95
|
+
if chunks and not delivered and not unknown:
|
|
96
|
+
raise SinkDeliveryError(f"KinesisSink delivered none of {chunks} chunk(s)")
|
|
97
|
+
|
|
98
|
+
def losses(self) -> SinkLosses:
|
|
99
|
+
"""Oversized drops, abandoned records and unadjudicable chunks (FR-002). Never raises.
|
|
100
|
+
|
|
101
|
+
``failed`` sums ``failed`` and ``dropped_unadjudicated``: both are records the stream
|
|
102
|
+
never confirmed. They stay apart on the instance, because "the stream said these failed"
|
|
103
|
+
and "the response did not describe them" have different remedies (SPEC-018).
|
|
104
|
+
"""
|
|
105
|
+
return SinkLosses(
|
|
106
|
+
dropped=self.dropped_oversized,
|
|
107
|
+
failed=self.failed + self.dropped_unadjudicated,
|
|
108
|
+
)
|
|
67
109
|
|
|
68
110
|
def close(self) -> None:
|
|
69
111
|
"""No-op: the sink buffers nothing internally (FR-001)."""
|
|
@@ -88,12 +130,20 @@ class KinesisSink:
|
|
|
88
130
|
records.append({"Data": data, "PartitionKey": key})
|
|
89
131
|
return records
|
|
90
132
|
|
|
91
|
-
def _send(self, records: list[dict[str, Any]]) -> None:
|
|
92
|
-
"""Send one chunk, retrying only the records the response flags as failed (FR-003).
|
|
133
|
+
def _send(self, records: list[dict[str, Any]]) -> int | None:
|
|
134
|
+
"""Send one chunk, retrying only the records the response flags as failed (FR-003).
|
|
135
|
+
|
|
136
|
+
Returns how many records the stream accepted, so ``emit`` can tell "nothing landed"
|
|
137
|
+
from a partial success — or ``None`` when the response could not be adjudicated, which
|
|
138
|
+
is neither. "The stream did not say" must not be read as "the stream took nothing":
|
|
139
|
+
that reading would make ``emit`` raise, and the worker's retry would re-send a chunk
|
|
140
|
+
SPEC-018 settled must never be re-sent.
|
|
141
|
+
"""
|
|
142
|
+
sent = len(records)
|
|
93
143
|
for attempt in range(self.max_retries + 1):
|
|
94
144
|
response = self.client.put_records(StreamName=self.stream_name, Records=records)
|
|
95
145
|
if not response.get("FailedRecordCount"):
|
|
96
|
-
return
|
|
146
|
+
return sent
|
|
97
147
|
results = usable_results(response.get("Records"))
|
|
98
148
|
verdict = adjudicate_positional(records, results)
|
|
99
149
|
if verdict.unadjudicated:
|
|
@@ -104,10 +154,10 @@ class KinesisSink:
|
|
|
104
154
|
f"KinesisSink could not adjudicate a put_records response ({len(records)} "
|
|
105
155
|
f"record(s) sent, {len(results)} result(s) returned); abandoned, not retried",
|
|
106
156
|
)
|
|
107
|
-
return
|
|
157
|
+
return None
|
|
108
158
|
records = verdict.retry
|
|
109
159
|
if not records:
|
|
110
|
-
return
|
|
160
|
+
return sent
|
|
111
161
|
if attempt >= self.max_retries:
|
|
112
162
|
self.failed += len(records)
|
|
113
163
|
_diag.lost(
|
|
@@ -115,4 +165,5 @@ class KinesisSink:
|
|
|
115
165
|
len(records),
|
|
116
166
|
f"KinesisSink, still failing after {self.max_retries + 1} attempts; abandoned",
|
|
117
167
|
)
|
|
118
|
-
return
|
|
168
|
+
return sent - len(records)
|
|
169
|
+
return 0 # unreachable: the loop returns on every path (mypy needs the exit)
|
|
@@ -14,6 +14,7 @@ import time
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from log_foundry import _diag
|
|
17
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
17
18
|
|
|
18
19
|
__all__ = ["MongoDBSink"]
|
|
19
20
|
|
|
@@ -46,13 +47,28 @@ class MongoDBSink:
|
|
|
46
47
|
client = MongoClient(uri)
|
|
47
48
|
self._client = client
|
|
48
49
|
self._collection = client[database][collection]
|
|
49
|
-
|
|
50
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
51
|
+
# having attempted no insert, and reported success.
|
|
52
|
+
self.max_retries = max(max_retries, 0)
|
|
50
53
|
self.failed = 0
|
|
51
54
|
self.dropped_oversized = 0
|
|
52
55
|
self._closed = False
|
|
53
56
|
|
|
57
|
+
def losses(self) -> SinkLosses:
|
|
58
|
+
"""Oversized drops and documents the server rejected or never took (FR-002)."""
|
|
59
|
+
return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
|
|
60
|
+
|
|
54
61
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
55
|
-
"""Insert every event unordered; count bulk failures, retry connection errors (FR-003).
|
|
62
|
+
"""Insert every event unordered; count bulk failures, retry connection errors (FR-003).
|
|
63
|
+
|
|
64
|
+
Raises when *nothing* was inserted (SPEC-026 FR-001) — a connection error past the retry
|
|
65
|
+
bound, or a bulk write every one of whose documents the server rejected. A bulk write
|
|
66
|
+
that stored some is partial and never raises: the unordered insert already committed
|
|
67
|
+
them, so the worker's retry would duplicate them.
|
|
68
|
+
|
|
69
|
+
A batch of nothing but oversized documents does not raise. They can never fit, so there
|
|
70
|
+
is nothing to retry; they are reported through ``losses().dropped``.
|
|
71
|
+
"""
|
|
56
72
|
documents = self._documents(batch)
|
|
57
73
|
if not documents:
|
|
58
74
|
return
|
|
@@ -67,9 +83,17 @@ class MongoDBSink:
|
|
|
67
83
|
# do not retry (successes are already in, a retry would duplicate/re-error).
|
|
68
84
|
rejects = len(details["writeErrors"])
|
|
69
85
|
self.failed += rejects
|
|
86
|
+
total = rejects >= len(documents)
|
|
70
87
|
_diag.lost(
|
|
71
|
-
"document",
|
|
88
|
+
"document",
|
|
89
|
+
rejects,
|
|
90
|
+
"MongoDBSink bulk write; none were inserted" if total
|
|
91
|
+
else "MongoDBSink bulk write; the rest were inserted",
|
|
72
92
|
)
|
|
93
|
+
if total:
|
|
94
|
+
raise SinkDeliveryError(
|
|
95
|
+
f"MongoDBSink inserted none of {len(documents)} document(s)"
|
|
96
|
+
) from None
|
|
73
97
|
return
|
|
74
98
|
if attempt < self.max_retries:
|
|
75
99
|
time.sleep(_BACKOFF_BASE * (2**attempt))
|
|
@@ -80,7 +104,9 @@ class MongoDBSink:
|
|
|
80
104
|
len(documents),
|
|
81
105
|
f"MongoDBSink, {self.max_retries + 1} attempts, {type(err).__name__}",
|
|
82
106
|
)
|
|
83
|
-
|
|
107
|
+
raise SinkDeliveryError(
|
|
108
|
+
f"MongoDBSink inserted none of {len(documents)} document(s)"
|
|
109
|
+
) from None
|
|
84
110
|
|
|
85
111
|
def close(self) -> None:
|
|
86
112
|
"""Close the client only if the sink owns it; idempotent (FR-005)."""
|
|
@@ -14,6 +14,7 @@ import json
|
|
|
14
14
|
from typing import Any
|
|
15
15
|
|
|
16
16
|
from log_foundry import _diag
|
|
17
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
17
18
|
|
|
18
19
|
__all__ = ["NATSSink"]
|
|
19
20
|
|
|
@@ -47,6 +48,10 @@ class NATSSink:
|
|
|
47
48
|
return
|
|
48
49
|
self._loop.run_until_complete(self._publish_all(batch))
|
|
49
50
|
|
|
51
|
+
def losses(self) -> SinkLosses:
|
|
52
|
+
"""Events whose publish raised (SPEC-026 FR-002). Never raises."""
|
|
53
|
+
return SinkLosses(dropped=0, failed=self.failed)
|
|
54
|
+
|
|
50
55
|
def close(self) -> None:
|
|
51
56
|
"""Drain/flush and close the connection, then close the managed loop (FR-007)."""
|
|
52
57
|
if self._loop.is_closed():
|
|
@@ -59,13 +64,23 @@ class NATSSink:
|
|
|
59
64
|
# -- internals ----------------------------------------------------------------------
|
|
60
65
|
|
|
61
66
|
async def _publish_all(self, batch: list[dict[str, object]]) -> None:
|
|
67
|
+
"""Publish each event, raising only when *none* of them landed (SPEC-026 FR-001).
|
|
68
|
+
|
|
69
|
+
Per-event isolation stays: a partial batch must not be retried wholesale, because the
|
|
70
|
+
events that published would be delivered twice.
|
|
71
|
+
"""
|
|
62
72
|
target = self._client.jetstream() if self._jetstream else self._client
|
|
73
|
+
published = 0
|
|
63
74
|
for event in batch:
|
|
64
75
|
try:
|
|
65
76
|
await target.publish(self._subject, json.dumps(event).encode("utf-8"))
|
|
66
|
-
except Exception as err: # isolation boundary:
|
|
77
|
+
except Exception as err: # isolation boundary: one event must not fail the batch
|
|
67
78
|
self.failed += 1
|
|
68
79
|
_diag.lost("event", 1, f"NATSSink publish, {type(err).__name__}")
|
|
80
|
+
else:
|
|
81
|
+
published += 1
|
|
82
|
+
if batch and not published:
|
|
83
|
+
raise SinkDeliveryError(f"NATSSink published none of {len(batch)} event(s)")
|
|
69
84
|
|
|
70
85
|
async def _drain(self) -> None:
|
|
71
86
|
drain = getattr(self._client, "drain", None)
|
|
@@ -15,6 +15,7 @@ from typing import Any
|
|
|
15
15
|
|
|
16
16
|
from log_foundry import _diag
|
|
17
17
|
from log_foundry.sinks._chunk import chunk_list, valid_identifier
|
|
18
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
18
19
|
|
|
19
20
|
__all__ = ["PostgresSink"]
|
|
20
21
|
|
|
@@ -39,7 +40,9 @@ class PostgresSink:
|
|
|
39
40
|
) -> None:
|
|
40
41
|
self._table = valid_identifier(table)
|
|
41
42
|
self._chunk_size = chunk_size
|
|
42
|
-
|
|
43
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
44
|
+
# having attempted no insert at all, and reported success.
|
|
45
|
+
self.max_retries = max(max_retries, 0)
|
|
43
46
|
self.failed = 0
|
|
44
47
|
self._closed = False
|
|
45
48
|
self._owns_connection = connection is None
|
|
@@ -54,8 +57,17 @@ class PostgresSink:
|
|
|
54
57
|
if create_table:
|
|
55
58
|
self._ensure_schema()
|
|
56
59
|
|
|
60
|
+
def losses(self) -> SinkLosses:
|
|
61
|
+
"""Events abandoned past the retry bound (SPEC-026 FR-002). Never raises."""
|
|
62
|
+
return SinkLosses(dropped=0, failed=self.failed)
|
|
63
|
+
|
|
57
64
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
58
|
-
"""Insert the whole batch in one transaction, rolling back and retrying on error (FR-004).
|
|
65
|
+
"""Insert the whole batch in one transaction, rolling back and retrying on error (FR-004).
|
|
66
|
+
|
|
67
|
+
One transaction, rolled back on failure, so a batch past the retry bound inserted
|
|
68
|
+
*nothing*: it is counted and then raised (SPEC-026 FR-001). The rollback is what makes
|
|
69
|
+
the worker's retry safe here — there are no committed rows to duplicate.
|
|
70
|
+
"""
|
|
59
71
|
if not batch:
|
|
60
72
|
return
|
|
61
73
|
for attempt in range(self.max_retries + 1):
|
|
@@ -80,7 +92,9 @@ class PostgresSink:
|
|
|
80
92
|
len(batch),
|
|
81
93
|
f"PostgresSink, {self.max_retries + 1} attempts, {type(err).__name__}",
|
|
82
94
|
)
|
|
83
|
-
|
|
95
|
+
raise SinkDeliveryError(
|
|
96
|
+
f"PostgresSink inserted none of {len(batch)} event(s)"
|
|
97
|
+
) from None
|
|
84
98
|
|
|
85
99
|
def close(self) -> None:
|
|
86
100
|
"""Commit pending work; close only an owned connection; idempotent (FR-005)."""
|
|
@@ -12,6 +12,7 @@ import json
|
|
|
12
12
|
from typing import Any
|
|
13
13
|
|
|
14
14
|
from log_foundry import _diag
|
|
15
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
15
16
|
|
|
16
17
|
__all__ = ["GooglePubSubSink"]
|
|
17
18
|
|
|
@@ -27,13 +28,41 @@ class GooglePubSubSink:
|
|
|
27
28
|
self.topic = topic
|
|
28
29
|
self.client = client
|
|
29
30
|
self.failed = 0
|
|
31
|
+
self.rejected = 0
|
|
30
32
|
self._futures: list[Any] = []
|
|
31
33
|
|
|
34
|
+
def losses(self) -> SinkLosses:
|
|
35
|
+
"""Refused publishes and futures that resolved to an error (SPEC-026 FR-002).
|
|
36
|
+
|
|
37
|
+
``failed`` only moves when the futures are resolved, which happens in :meth:`close` —
|
|
38
|
+
so a long-lived process reads zero here until it shuts down. That is a property of the
|
|
39
|
+
client's asynchronous publish, not of this accessor. Never raises.
|
|
40
|
+
"""
|
|
41
|
+
return SinkLosses(dropped=self.rejected, failed=self.failed)
|
|
42
|
+
|
|
32
43
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
33
|
-
"""Publish one message per event, retaining each future for flush on close (FR-008).
|
|
44
|
+
"""Publish one message per event, retaining each future for flush on close (FR-008).
|
|
45
|
+
|
|
46
|
+
``publish()`` is a local hand-off returning a future, so a refusal here is the only
|
|
47
|
+
failure this call can observe; it is isolated per event and raises only when *every*
|
|
48
|
+
event was refused (SPEC-026 FR-001). Letting the first refusal propagate would hand the
|
|
49
|
+
worker a batch whose earlier events are already in flight, and the retry would duplicate
|
|
50
|
+
them.
|
|
51
|
+
"""
|
|
52
|
+
published = 0
|
|
34
53
|
for event in batch:
|
|
35
|
-
|
|
54
|
+
try:
|
|
55
|
+
future = self.client.publish(self.topic, data=json.dumps(event).encode("utf-8"))
|
|
56
|
+
except Exception as err: # isolation boundary: one event must not fail the batch
|
|
57
|
+
self.rejected += 1
|
|
58
|
+
_diag.lost("event", 1, f"GooglePubSubSink publish, {type(err).__name__}")
|
|
59
|
+
continue
|
|
36
60
|
self._futures.append(future)
|
|
61
|
+
published += 1
|
|
62
|
+
if batch and not published:
|
|
63
|
+
raise SinkDeliveryError(
|
|
64
|
+
f"GooglePubSubSink published none of {len(batch)} event(s)"
|
|
65
|
+
)
|
|
37
66
|
|
|
38
67
|
def close(self) -> None:
|
|
39
68
|
"""Resolve all pending publish futures, counting/logging errors; idempotent (FR-008)."""
|
|
@@ -13,6 +13,7 @@ import time
|
|
|
13
13
|
from typing import Any
|
|
14
14
|
|
|
15
15
|
from log_foundry import _diag
|
|
16
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
16
17
|
|
|
17
18
|
__all__ = ["RabbitMQSink"]
|
|
18
19
|
|
|
@@ -45,17 +46,32 @@ class RabbitMQSink:
|
|
|
45
46
|
self._exchange = exchange
|
|
46
47
|
self._routing_key = routing_key
|
|
47
48
|
self._url = url
|
|
48
|
-
|
|
49
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value abandoned
|
|
50
|
+
# each message with no attempt made and no counter moved.
|
|
51
|
+
self._max_retries = max(max_retries, 0)
|
|
49
52
|
self._owns_connection = connection is None
|
|
50
53
|
self._connection = connection if connection is not None else self._connect()
|
|
51
54
|
self._channel: Any = None
|
|
52
55
|
self._properties: Any = None
|
|
53
56
|
self.failed = 0
|
|
54
57
|
|
|
58
|
+
def losses(self) -> SinkLosses:
|
|
59
|
+
"""Messages abandoned past the reconnect-retry bound (SPEC-026 FR-002). Never raises."""
|
|
60
|
+
return SinkLosses(dropped=0, failed=self.failed)
|
|
61
|
+
|
|
55
62
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
56
|
-
"""Publish one persistent message per event, reconnecting on error (FR-006).
|
|
63
|
+
"""Publish one persistent message per event, reconnecting on error (FR-006).
|
|
64
|
+
|
|
65
|
+
Raises when *no* message reached the broker (SPEC-026 FR-001) — a down broker is the
|
|
66
|
+
case the worker's retry and ``failed_batches`` exist for. A partial publish is counted
|
|
67
|
+
and left alone: retrying it would re-publish the messages already on the exchange.
|
|
68
|
+
"""
|
|
69
|
+
published = 0
|
|
57
70
|
for event in batch:
|
|
58
|
-
self._publish(json.dumps(event).encode("utf-8"))
|
|
71
|
+
if self._publish(json.dumps(event).encode("utf-8")):
|
|
72
|
+
published += 1
|
|
73
|
+
if batch and not published:
|
|
74
|
+
raise SinkDeliveryError(f"RabbitMQSink published none of {len(batch)} message(s)")
|
|
59
75
|
|
|
60
76
|
def close(self) -> None:
|
|
61
77
|
"""Close the channel and (owned) connection; idempotent (FR-006)."""
|
|
@@ -68,7 +84,8 @@ class RabbitMQSink:
|
|
|
68
84
|
|
|
69
85
|
# -- internals ----------------------------------------------------------------------
|
|
70
86
|
|
|
71
|
-
def _publish(self, body: bytes) ->
|
|
87
|
+
def _publish(self, body: bytes) -> bool:
|
|
88
|
+
"""Publish one message within the retry bound; ``False`` once it is abandoned."""
|
|
72
89
|
for attempt in range(self._max_retries + 1):
|
|
73
90
|
try:
|
|
74
91
|
self._active_channel().basic_publish(
|
|
@@ -77,7 +94,7 @@ class RabbitMQSink:
|
|
|
77
94
|
body=body,
|
|
78
95
|
properties=self._persistent_properties(),
|
|
79
96
|
)
|
|
80
|
-
return
|
|
97
|
+
return True
|
|
81
98
|
except Exception as err: # isolation boundary: never crash the worker (FR-011)
|
|
82
99
|
self._reset()
|
|
83
100
|
if attempt < self._max_retries:
|
|
@@ -89,7 +106,8 @@ class RabbitMQSink:
|
|
|
89
106
|
1,
|
|
90
107
|
f"RabbitMQSink, {self._max_retries + 1} attempts, {type(err).__name__}",
|
|
91
108
|
)
|
|
92
|
-
return
|
|
109
|
+
return False
|
|
110
|
+
return False # unreachable: the loop returns on every path (mypy needs the exit)
|
|
93
111
|
|
|
94
112
|
def _active_channel(self) -> Any:
|
|
95
113
|
if self._connection is None:
|
|
@@ -15,6 +15,7 @@ import time
|
|
|
15
15
|
from typing import Any
|
|
16
16
|
|
|
17
17
|
from log_foundry import _diag
|
|
18
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
18
19
|
|
|
19
20
|
__all__ = ["RedisListSink", "RedisStreamsSink"]
|
|
20
21
|
|
|
@@ -31,11 +32,22 @@ class _RedisSink:
|
|
|
31
32
|
|
|
32
33
|
client = redis.Redis.from_url(url) if url else redis.Redis()
|
|
33
34
|
self.client = client
|
|
34
|
-
|
|
35
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value made the
|
|
36
|
+
# retry range empty, so ``emit`` returned having attempted nothing — a silent success.
|
|
37
|
+
self.max_retries = max(max_retries, 0)
|
|
35
38
|
self.failed = 0
|
|
36
39
|
|
|
40
|
+
def losses(self) -> SinkLosses:
|
|
41
|
+
"""Events abandoned past the retry bound (SPEC-026 FR-002). Never raises."""
|
|
42
|
+
return SinkLosses(dropped=0, failed=self.failed)
|
|
43
|
+
|
|
37
44
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
38
|
-
"""Pipeline the whole batch into one round trip, retrying on connection error (FR-005).
|
|
45
|
+
"""Pipeline the whole batch into one round trip, retrying on connection error (FR-005).
|
|
46
|
+
|
|
47
|
+
The batch travels as one pipeline, so a failure past the retry bound delivered *nothing*:
|
|
48
|
+
it is counted and then raised, giving the worker its retry and ``health()`` the loss
|
|
49
|
+
(SPEC-026 FR-001). There is no partial case to protect — the pipeline is all or nothing.
|
|
50
|
+
"""
|
|
39
51
|
if not batch:
|
|
40
52
|
return
|
|
41
53
|
for attempt in range(self.max_retries + 1):
|
|
@@ -55,7 +67,9 @@ class _RedisSink:
|
|
|
55
67
|
len(batch),
|
|
56
68
|
f"{type(self).__name__}, {self.max_retries + 1} attempts, {type(err).__name__}",
|
|
57
69
|
)
|
|
58
|
-
|
|
70
|
+
raise SinkDeliveryError(
|
|
71
|
+
f"{type(self).__name__} delivered none of {len(batch)} event(s)"
|
|
72
|
+
) from None
|
|
59
73
|
|
|
60
74
|
def close(self) -> None:
|
|
61
75
|
"""Close the connection only if the sink owns it (FR-005)."""
|
|
@@ -12,6 +12,7 @@ from typing import Any
|
|
|
12
12
|
|
|
13
13
|
from log_foundry import _diag
|
|
14
14
|
from log_foundry.sinks._chunk import chunk_items
|
|
15
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
15
16
|
|
|
16
17
|
__all__ = ["SNSSink"]
|
|
17
18
|
|
|
@@ -29,17 +30,33 @@ class SNSSink:
|
|
|
29
30
|
client = boto3.client("sns")
|
|
30
31
|
self.topic_arn = topic_arn
|
|
31
32
|
self.client = client
|
|
32
|
-
|
|
33
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
34
|
+
# from ``_send`` having published nothing, and reported success.
|
|
35
|
+
self.max_retries = max(max_retries, 0)
|
|
33
36
|
self.failed = 0
|
|
34
37
|
self.dropped_oversized = 0
|
|
35
38
|
|
|
39
|
+
def losses(self) -> SinkLosses:
|
|
40
|
+
"""Oversized drops and entries still failing past the retry bound (FR-002)."""
|
|
41
|
+
return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
|
|
42
|
+
|
|
36
43
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
37
|
-
"""Re-chunk to publish_batch limits and send each chunk, retrying failures (FR-010).
|
|
44
|
+
"""Re-chunk to publish_batch limits and send each chunk, retrying failures (FR-010).
|
|
45
|
+
|
|
46
|
+
Raises when every chunk failed and at least one was sent (SPEC-026 FR-001). Events
|
|
47
|
+
dropped before sending — too large to ever fit — are not a send failure and do not
|
|
48
|
+
make a batch of nothing but oversized events raise; they can never be retried into
|
|
49
|
+
existence, and are reported through ``losses().dropped``.
|
|
50
|
+
"""
|
|
38
51
|
bodies = self._bodies(batch)
|
|
52
|
+
chunks = delivered = 0
|
|
39
53
|
for chunk in chunk_items(
|
|
40
54
|
bodies, max_count=self.MAX_BATCH, max_bytes=self.MAX_BYTES, size_of=len
|
|
41
55
|
):
|
|
42
|
-
|
|
56
|
+
chunks += 1
|
|
57
|
+
delivered += self._send(chunk)
|
|
58
|
+
if chunks and not delivered:
|
|
59
|
+
raise SinkDeliveryError(f"SNSSink published none of {chunks} chunk(s)")
|
|
43
60
|
|
|
44
61
|
def close(self) -> None:
|
|
45
62
|
"""No-op: the sink buffers nothing internally (FR-001)."""
|
|
@@ -58,8 +75,13 @@ class SNSSink:
|
|
|
58
75
|
bodies.append(body)
|
|
59
76
|
return bodies
|
|
60
77
|
|
|
61
|
-
def _send(self, bodies: list[str]) ->
|
|
62
|
-
"""Publish one chunk, retrying only the ``Failed`` entries (bounded) (FR-010).
|
|
78
|
+
def _send(self, bodies: list[str]) -> int:
|
|
79
|
+
"""Publish one chunk, retrying only the ``Failed`` entries (bounded) (FR-010).
|
|
80
|
+
|
|
81
|
+
Returns how many entries SNS accepted, so ``emit`` can tell "nothing landed" from a
|
|
82
|
+
partial success. A chunk whose entries all failed contributes ``0``.
|
|
83
|
+
"""
|
|
84
|
+
sent = len(bodies)
|
|
63
85
|
entries = [{"Id": str(i), "Message": body} for i, body in enumerate(bodies)]
|
|
64
86
|
for attempt in range(self.max_retries + 1):
|
|
65
87
|
response = self.client.publish_batch(
|
|
@@ -67,7 +89,7 @@ class SNSSink:
|
|
|
67
89
|
)
|
|
68
90
|
failed = response.get("Failed", [])
|
|
69
91
|
if not failed:
|
|
70
|
-
return
|
|
92
|
+
return sent
|
|
71
93
|
failed_ids = {entry["Id"] for entry in failed}
|
|
72
94
|
entries = [entry for entry in entries if entry["Id"] in failed_ids]
|
|
73
95
|
if attempt >= self.max_retries:
|
|
@@ -77,4 +99,5 @@ class SNSSink:
|
|
|
77
99
|
len(entries),
|
|
78
100
|
f"SNSSink, still failing after {self.max_retries + 1} attempts; abandoned",
|
|
79
101
|
)
|
|
80
|
-
return
|
|
102
|
+
return sent - len(entries)
|
|
103
|
+
return 0 # unreachable: the loop returns on every path (mypy needs the exit)
|
|
@@ -35,6 +35,7 @@ from collections.abc import Callable
|
|
|
35
35
|
from typing import Any, NamedTuple
|
|
36
36
|
|
|
37
37
|
from log_foundry import _diag
|
|
38
|
+
from log_foundry.sinks.base import SinkDeliveryError, SinkLosses
|
|
38
39
|
|
|
39
40
|
__all__ = ["SQSSink"]
|
|
40
41
|
|
|
@@ -95,7 +96,9 @@ class SQSSink:
|
|
|
95
96
|
client = boto3.client("sqs")
|
|
96
97
|
self.queue_url = queue_url
|
|
97
98
|
self.client = client
|
|
98
|
-
|
|
99
|
+
# Floored as ``Worker._emit`` floors its own (SPEC-021): a negative value returned
|
|
100
|
+
# from ``_send`` having sent nothing, and reported success.
|
|
101
|
+
self.max_retries = max(max_retries, 0)
|
|
99
102
|
# AWS requires every FIFO queue name to end in '.fifo', so the suffix is a contract
|
|
100
103
|
# rather than a guess — but an explicit flag still wins. Decided once, not per emit.
|
|
101
104
|
self.fifo = queue_url.endswith(".fifo") if fifo is None else fifo
|
|
@@ -104,10 +107,45 @@ class SQSSink:
|
|
|
104
107
|
self.dropped_oversized = 0 # events too large to ever fit one message
|
|
105
108
|
self.failed = 0 # entries still failing after the retry bound
|
|
106
109
|
|
|
110
|
+
def losses(self) -> SinkLosses:
|
|
111
|
+
"""Oversized drops and entries still failing past the retry bound (FR-002).
|
|
112
|
+
|
|
113
|
+
Sender faults land in ``failed`` alongside the retry-exhausted entries: SQS rejected the
|
|
114
|
+
request itself, so the entry is as lost as one that timed out, and SPEC-016 settled that
|
|
115
|
+
it must not be re-sent byte-identical. Never raises.
|
|
116
|
+
"""
|
|
117
|
+
return SinkLosses(dropped=self.dropped_oversized, failed=self.failed)
|
|
118
|
+
|
|
107
119
|
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
108
|
-
"""Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002).
|
|
120
|
+
"""Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002).
|
|
121
|
+
|
|
122
|
+
Raises when every chunk failed and at least one was sent (SPEC-026 FR-001). An event
|
|
123
|
+
dropped for exceeding the message limit is not a send failure — it can never fit — so a
|
|
124
|
+
batch of nothing but oversized events produces no chunks and does not raise; it is
|
|
125
|
+
reported through ``losses().dropped``.
|
|
126
|
+
|
|
127
|
+
Nor does a batch whose chunks SQS rejected *entirely* as sender faults. Those entries
|
|
128
|
+
are lost, but re-sending them byte-identical can only fail the same way, and SPEC-016
|
|
129
|
+
FR-006 settled that they are abandoned rather than retried — so making the worker retry
|
|
130
|
+
them would undo that decision one level up. They are reported through ``losses().failed``.
|
|
131
|
+
|
|
132
|
+
That suppression is conditional, not batch-wide. If any chunk was lost for a *retryable*
|
|
133
|
+
reason — a throttle, an internal error, still failing at the bound — the raise stands,
|
|
134
|
+
because those events are recoverable and nothing landed for the retry to duplicate. The
|
|
135
|
+
cost is that the sender-fault chunk is re-sent and re-rejected alongside them, which is
|
|
136
|
+
futile but harmless; silently dropping recoverable events to avoid it would be the
|
|
137
|
+
failure SPEC-026 exists to remove. A batch of nothing *but* sender faults still keeps
|
|
138
|
+
FR-006 exactly.
|
|
139
|
+
"""
|
|
140
|
+
chunks = delivered = 0
|
|
141
|
+
recoverable_loss = False
|
|
109
142
|
for chunk in self._chunks(batch):
|
|
110
|
-
|
|
143
|
+
chunks += 1
|
|
144
|
+
accepted, retryable_lost = self._send(chunk)
|
|
145
|
+
delivered += accepted
|
|
146
|
+
recoverable_loss = recoverable_loss or retryable_lost
|
|
147
|
+
if chunks and not delivered and recoverable_loss:
|
|
148
|
+
raise SinkDeliveryError(f"SQSSink delivered none of {chunks} chunk(s)")
|
|
111
149
|
|
|
112
150
|
def close(self) -> None:
|
|
113
151
|
"""No-op: the sink buffers nothing internally (FR-005)."""
|
|
@@ -192,9 +230,15 @@ class SQSSink:
|
|
|
192
230
|
chunks.append(current)
|
|
193
231
|
return chunks
|
|
194
232
|
|
|
195
|
-
def _send(self, prepared: list[_Prepared]) ->
|
|
233
|
+
def _send(self, prepared: list[_Prepared]) -> tuple[int, bool]:
|
|
196
234
|
"""Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
|
|
197
235
|
|
|
236
|
+
Returns ``(accepted, retryable_lost)``. ``accepted`` lets ``emit`` tell "nothing landed"
|
|
237
|
+
from a partial success (SPEC-026 FR-001); ``retryable_lost`` says whether anything was
|
|
238
|
+
given up on that a re-send could plausibly recover. A chunk SQS rejected wholesale as
|
|
239
|
+
invalid reports ``False`` there, because re-sending those entries byte-identical can
|
|
240
|
+
only fail the same way (SPEC-016 FR-006).
|
|
241
|
+
|
|
198
242
|
Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
|
|
199
243
|
are counted (``failed``) and logged, not silently dropped (FR-003). The FIFO parameters
|
|
200
244
|
are attached only when the queue is FIFO, so a standard queue's entries are exactly
|
|
@@ -213,11 +257,21 @@ class SQSSink:
|
|
|
213
257
|
if item.dedup_id is not None:
|
|
214
258
|
entry["MessageDeduplicationId"] = item.dedup_id
|
|
215
259
|
entries.append(entry)
|
|
260
|
+
accepted = 0
|
|
216
261
|
for attempt in range(self.max_retries + 1):
|
|
217
262
|
response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
|
|
218
263
|
failed = response.get("Failed", [])
|
|
264
|
+
# Accumulated per attempt, not derived at the end: after the first response
|
|
265
|
+
# ``entries`` is already narrowed to the failures, so each round's difference is
|
|
266
|
+
# what that round put on the queue.
|
|
267
|
+
# Matched by ``Id`` rather than counted: a ``Failed`` array carrying a duplicate or
|
|
268
|
+
# an unknown id would otherwise understate ``accepted`` — possibly below zero — and
|
|
269
|
+
# turn a partial success into a false "nothing landed". Unreachable from real SQS,
|
|
270
|
+
# guarded anyway because the cost is one set intersection.
|
|
271
|
+
failed_ids = {item.get("Id") for item in failed}
|
|
272
|
+
accepted += sum(1 for entry in entries if entry["Id"] not in failed_ids)
|
|
219
273
|
if not failed:
|
|
220
|
-
return
|
|
274
|
+
return accepted, False
|
|
221
275
|
# Abandon sender faults immediately and name the code: the retry would re-send the
|
|
222
276
|
# entry byte-identical, and the code is the only thing that makes a rejection
|
|
223
277
|
# diagnosable from the log line alone. A missing flag is treated as retryable, so
|
|
@@ -237,7 +291,10 @@ class SQSSink:
|
|
|
237
291
|
|
|
238
292
|
retryable_ids = {item["Id"] for item in failed if not item.get("SenderFault")}
|
|
239
293
|
if not retryable_ids:
|
|
240
|
-
|
|
294
|
+
# Everything left is a sender fault: counted, and not worth re-sending
|
|
295
|
+
# (SPEC-016 FR-006). ``False`` keeps this chunk alone from making ``emit``
|
|
296
|
+
# report a total failure the worker would answer with that re-send.
|
|
297
|
+
return accepted, False
|
|
241
298
|
entries = [entry for entry in entries if entry["Id"] in retryable_ids]
|
|
242
299
|
if attempt >= self.max_retries:
|
|
243
300
|
self.failed += len(entries)
|
|
@@ -246,4 +303,5 @@ class SQSSink:
|
|
|
246
303
|
len(entries),
|
|
247
304
|
f"SQSSink, still failing after {self.max_retries + 1} attempts; abandoned",
|
|
248
305
|
)
|
|
249
|
-
return
|
|
306
|
+
return accepted, True
|
|
307
|
+
return accepted, False # unreachable: the loop returns on every path
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|