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.
Files changed (54) hide show
  1. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/clickhouse.py +24 -7
  4. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/eventhubs.py +42 -11
  5. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/firehose.py +61 -9
  6. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/kafka.py +36 -2
  7. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/kinesis.py +60 -9
  8. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/mongodb.py +30 -4
  9. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/nats.py +16 -1
  10. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/postgres.py +17 -3
  11. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/pubsub.py +31 -2
  12. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/rabbitmq.py +24 -6
  13. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/redis.py +17 -3
  14. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sns.py +30 -7
  15. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sqs.py +65 -7
  16. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/LICENSE +0 -0
  17. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/README.md +0 -0
  18. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/__init__.py +0 -0
  19. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/_diag.py +0 -0
  20. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/api.py +0 -0
  21. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/config.py +0 -0
  22. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/console.py +0 -0
  23. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/context.py +0 -0
  24. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/decorator.py +0 -0
  25. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/ids.py +0 -0
  26. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/model.py +0 -0
  27. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/py.typed +0 -0
  28. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sanitize.py +0 -0
  29. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/__init__.py +0 -0
  30. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_batch.py +0 -0
  31. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_chunk.py +0 -0
  32. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_socket.py +0 -0
  33. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/_time.py +0 -0
  34. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/base.py +0 -0
  35. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/callback.py +0 -0
  36. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/datadog.py +0 -0
  37. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/elasticsearch.py +0 -0
  38. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/file.py +0 -0
  39. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/filtering.py +0 -0
  40. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/honeycomb.py +0 -0
  41. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/http.py +0 -0
  42. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/logging_sink.py +0 -0
  43. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/logstash.py +0 -0
  44. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/loki.py +0 -0
  45. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/multi.py +0 -0
  46. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/newrelic.py +0 -0
  47. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/splunk.py +0 -0
  49. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/sqlite.py +0 -0
  50. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/stdout.py +0 -0
  51. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/syslog.py +0 -0
  52. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/transform.py +0 -0
  53. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/sinks/util.py +0 -0
  54. {log_foundry-0.10.2.dev17 → log_foundry-0.10.2.dev18}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev17
3
+ Version: 0.10.2.dev18
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev17"
23
+ version = "0.10.2.dev18"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -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
- self.max_retries = max_retries
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
- rows = [self._row(event) for event in chunk]
75
- self._insert(rows)
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]]) -> None:
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
- self.max_retries = max_retries
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
- self._send(current)
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
- self._send(current)
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) -> None:
76
- """Send one EventDataBatch (skipping an empty one), retrying failures (FR-009, FR-011)."""
77
- if len(event_batch) == 0:
78
- return
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
- self.max_retries = max_retries
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
- self._send(chunk)
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
- def _send(self, records: list[dict[str, Any]]) -> None:
83
- """Send one chunk, retrying only the entries the response flags as failed (FR-004)."""
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
- self.producer.produce(self.topic, value=body, key=key, callback=self._on_delivery)
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
- self.max_retries = max_retries
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
- self._send(chunk)
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
- self.max_retries = max_retries
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", rejects, "MongoDBSink bulk write; the rest were inserted"
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
- return
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: never crash the worker (FR-011)
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
- self.max_retries = max_retries
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
- return
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
- future = self.client.publish(self.topic, data=json.dumps(event).encode("utf-8"))
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
- self._max_retries = max_retries
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) -> None:
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
- self.max_retries = max_retries
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
- return
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
- self.max_retries = max_retries
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
- self._send(chunk)
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]) -> None:
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
- self.max_retries = max_retries
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
- self._send(chunk)
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]) -> None:
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
- return
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