log-foundry 0.4.1.dev1__tar.gz → 0.4.1.dev3__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.4.1.dev1 → log_foundry-0.4.1.dev3}/PKG-INFO +1 -1
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/pyproject.toml +1 -1
- log_foundry-0.4.1.dev3/src/log_foundry/sinks/sqs.py +219 -0
- log_foundry-0.4.1.dev1/src/log_foundry/sinks/sqs.py +0 -107
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/LICENSE +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/README.md +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/api.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/config.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/console.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/context.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/model.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/sinks/util.py +0 -0
- {log_foundry-0.4.1.dev1 → log_foundry-0.4.1.dev3}/src/log_foundry/worker.py +0 -0
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
"""SQSSink — ship batches to an SQS queue (arch §8, §9.1, guide Phase 10).
|
|
2
|
+
|
|
3
|
+
SQS is the headline production path: a durable buffer that decouples the app from ELK
|
|
4
|
+
availability — events accumulate safely in the queue during downstream spikes/outages instead
|
|
5
|
+
of being lost or back-pressuring the app. A separate consumer indexes them into ELK (out of
|
|
6
|
+
scope here). Like every sink this receives *already-built* event dicts and knows nothing about
|
|
7
|
+
spans.
|
|
8
|
+
|
|
9
|
+
``boto3`` is an **optional** dependency (the ``aws`` extra): it is imported lazily inside the
|
|
10
|
+
sink, never at module top, so ``import log_foundry.sinks.sqs`` — and the whole library — stays
|
|
11
|
+
dependency-free unless an ``SQSSink`` is actually instantiated without an injected client.
|
|
12
|
+
|
|
13
|
+
The worker (SPEC-004) batches by count and time, but SQS has hard per-request limits, so this
|
|
14
|
+
sink re-chunks every incoming batch on both dimensions: ≤ 10 messages **and** ≤ 256 KB per
|
|
15
|
+
``send_message_batch``. Partial failures (the response ``Failed`` list) are retried; a single
|
|
16
|
+
event too large to ever fit is dropped with a warning rather than crashing the batch.
|
|
17
|
+
|
|
18
|
+
Both queue types are supported (SPEC-016). A ``.fifo`` queue URL selects FIFO behaviour, where
|
|
19
|
+
every entry additionally carries a ``MessageGroupId`` — SQS orders messages *within* a group, and
|
|
20
|
+
the default group is the event's own ``trace_id``, which is exactly the unit whose events should
|
|
21
|
+
stay ordered. Per-trace groups also keep traces independent, so the queue delivers them in
|
|
22
|
+
parallel instead of serializing the whole process behind one group. Standard queues are
|
|
23
|
+
untouched: their entries carry ``Id`` and ``MessageBody`` and nothing else.
|
|
24
|
+
|
|
25
|
+
Ordering is best-effort across a retry boundary: if one entry fails while a same-group entry
|
|
26
|
+
ahead of it succeeded, the retried entry lands *after* it. Holding a whole group back on one
|
|
27
|
+
failure would trade log delivery for ordering the consumer can rebuild from ``timestamp``, so it
|
|
28
|
+
is not done.
|
|
29
|
+
"""
|
|
30
|
+
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import json
|
|
34
|
+
import sys
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
from typing import Any, NamedTuple
|
|
37
|
+
|
|
38
|
+
__all__ = ["SQSSink"]
|
|
39
|
+
|
|
40
|
+
DEFAULT_GROUP_ID = "log-foundry"
|
|
41
|
+
"""Fallback group for an event carrying no usable ``trace_id`` — never send an empty group id."""
|
|
42
|
+
|
|
43
|
+
MAX_ID_LEN = 128
|
|
44
|
+
"""SQS maximum length for ``MessageGroupId`` and ``MessageDeduplicationId``."""
|
|
45
|
+
|
|
46
|
+
GroupIdSource = str | Callable[[dict[str, object]], str] | None
|
|
47
|
+
DedupIdSource = Callable[[dict[str, object]], str] | None
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class _Prepared(NamedTuple):
|
|
51
|
+
"""One event serialized and costed, with its FIFO ids resolved (``None`` on a standard queue).
|
|
52
|
+
|
|
53
|
+
The ids are derived **once**, here, rather than again at send time: the deduplication
|
|
54
|
+
fallback mints a fresh UUID, so deriving twice would bill the byte budget for one value and
|
|
55
|
+
put a different one on the wire.
|
|
56
|
+
"""
|
|
57
|
+
|
|
58
|
+
body: str
|
|
59
|
+
group_id: str | None
|
|
60
|
+
dedup_id: str | None
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bounded(raw: str, fallback: str) -> str:
|
|
64
|
+
"""Normalize a derived id: blank falls back, over-long truncates to the SQS maximum."""
|
|
65
|
+
cleaned = raw.strip()
|
|
66
|
+
if not cleaned:
|
|
67
|
+
return fallback
|
|
68
|
+
return cleaned[:MAX_ID_LEN]
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
class SQSSink:
|
|
72
|
+
"""A :class:`~log_foundry.sinks.base.Sink` that sends events to an SQS queue."""
|
|
73
|
+
|
|
74
|
+
MAX_BATCH = 10 # SQS SendMessageBatch hard limit: entries per request
|
|
75
|
+
MAX_BYTES = 256 * 1024 # SQS limit: 256 KB per request
|
|
76
|
+
|
|
77
|
+
def __init__(
|
|
78
|
+
self,
|
|
79
|
+
queue_url: str,
|
|
80
|
+
client: Any = None,
|
|
81
|
+
*,
|
|
82
|
+
max_retries: int = 3,
|
|
83
|
+
fifo: bool | None = None,
|
|
84
|
+
message_group_id: GroupIdSource = None,
|
|
85
|
+
message_deduplication_id: DedupIdSource = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
if isinstance(message_group_id, str) and not message_group_id.strip():
|
|
88
|
+
raise ValueError(
|
|
89
|
+
"message_group_id must be a non-empty string; pass None to group by trace_id"
|
|
90
|
+
)
|
|
91
|
+
if client is None:
|
|
92
|
+
import boto3 # type: ignore[import-not-found] # optional 'aws' extra
|
|
93
|
+
|
|
94
|
+
client = boto3.client("sqs")
|
|
95
|
+
self.queue_url = queue_url
|
|
96
|
+
self.client = client
|
|
97
|
+
self.max_retries = max_retries
|
|
98
|
+
# AWS requires every FIFO queue name to end in '.fifo', so the suffix is a contract
|
|
99
|
+
# rather than a guess — but an explicit flag still wins. Decided once, not per emit.
|
|
100
|
+
self.fifo = queue_url.endswith(".fifo") if fifo is None else fifo
|
|
101
|
+
self.message_group_id = message_group_id
|
|
102
|
+
self.message_deduplication_id = message_deduplication_id
|
|
103
|
+
self.dropped_oversized = 0 # events too large to ever fit one message
|
|
104
|
+
self.failed = 0 # entries still failing after the retry bound
|
|
105
|
+
|
|
106
|
+
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
107
|
+
"""Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002)."""
|
|
108
|
+
for chunk in self._chunks(batch):
|
|
109
|
+
self._send(chunk)
|
|
110
|
+
|
|
111
|
+
def close(self) -> None:
|
|
112
|
+
"""No-op: the sink buffers nothing internally (FR-005)."""
|
|
113
|
+
|
|
114
|
+
# -- internals ----------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
def _group_id(self, event: dict[str, object]) -> str:
|
|
117
|
+
"""Resolve one event's ``MessageGroupId`` (SPEC-016 FR-002).
|
|
118
|
+
|
|
119
|
+
A constant is used as given; a callable is asked; otherwise the event's own
|
|
120
|
+
``trace_id`` groups it. Every route is bounded by :func:`_bounded`, so a caller's
|
|
121
|
+
callable returning ``""`` cannot put an empty parameter on the wire.
|
|
122
|
+
"""
|
|
123
|
+
source = self.message_group_id
|
|
124
|
+
if isinstance(source, str):
|
|
125
|
+
raw = source
|
|
126
|
+
elif source is not None:
|
|
127
|
+
raw = source(event)
|
|
128
|
+
else:
|
|
129
|
+
raw = str(event.get("trace_id", ""))
|
|
130
|
+
return _bounded(raw, DEFAULT_GROUP_ID)
|
|
131
|
+
|
|
132
|
+
def _dedup_id(self, event: dict[str, object]) -> str:
|
|
133
|
+
"""Resolve one event's ``MessageDeduplicationId`` (SPEC-016 FR-003).
|
|
134
|
+
|
|
135
|
+
Defaults to the event's ``log_id`` — already a per-event UUID, so SQS's five-minute
|
|
136
|
+
deduplication window can never collapse two genuinely distinct records. The fallback
|
|
137
|
+
mints a fresh id for the same reason: a shared constant would make unrelated events
|
|
138
|
+
deduplicate each other.
|
|
139
|
+
"""
|
|
140
|
+
source = self.message_deduplication_id
|
|
141
|
+
raw = source(event) if source is not None else str(event.get("log_id", ""))
|
|
142
|
+
if raw.strip():
|
|
143
|
+
return _bounded(raw, "")
|
|
144
|
+
from log_foundry.ids import new_log_id
|
|
145
|
+
|
|
146
|
+
return new_log_id()
|
|
147
|
+
|
|
148
|
+
def _chunks(self, batch: list[dict[str, object]]) -> list[list[_Prepared]]:
|
|
149
|
+
"""Split ``batch`` into sends of ≤ ``MAX_BATCH`` entries and ≤ ``MAX_BYTES`` each.
|
|
150
|
+
|
|
151
|
+
Each event is serialized once with ``json.dumps``. An event whose serialized size
|
|
152
|
+
exceeds ``MAX_BYTES`` on its own can never fit a message, so it is dropped with a
|
|
153
|
+
counted warning (FR-004) instead of stalling the batch — judged on the body alone, so
|
|
154
|
+
an event's droppability does not depend on which queue type it is bound for.
|
|
155
|
+
|
|
156
|
+
On a FIFO queue the resolved ids are costed alongside the body (SPEC-016 FR-005): they
|
|
157
|
+
travel in the same request, so leaving them out of the budget could overshoot 256 KB.
|
|
158
|
+
"""
|
|
159
|
+
chunks: list[list[_Prepared]] = []
|
|
160
|
+
current: list[_Prepared] = []
|
|
161
|
+
current_bytes = 0
|
|
162
|
+
for event in batch:
|
|
163
|
+
body = json.dumps(event)
|
|
164
|
+
size = len(body.encode("utf-8"))
|
|
165
|
+
if size > self.MAX_BYTES:
|
|
166
|
+
self.dropped_oversized += 1
|
|
167
|
+
sys.stderr.write(
|
|
168
|
+
f"log-foundry: dropped an event of {size} bytes exceeding the "
|
|
169
|
+
f"{self.MAX_BYTES}-byte SQS message limit\n"
|
|
170
|
+
)
|
|
171
|
+
continue
|
|
172
|
+
group_id: str | None = None
|
|
173
|
+
dedup_id: str | None = None
|
|
174
|
+
if self.fifo:
|
|
175
|
+
group_id = self._group_id(event)
|
|
176
|
+
dedup_id = self._dedup_id(event)
|
|
177
|
+
size += len(group_id.encode("utf-8")) + len(dedup_id.encode("utf-8"))
|
|
178
|
+
if current and (
|
|
179
|
+
len(current) >= self.MAX_BATCH or current_bytes + size > self.MAX_BYTES
|
|
180
|
+
):
|
|
181
|
+
chunks.append(current)
|
|
182
|
+
current = []
|
|
183
|
+
current_bytes = 0
|
|
184
|
+
current.append(_Prepared(body, group_id, dedup_id))
|
|
185
|
+
current_bytes += size
|
|
186
|
+
if current:
|
|
187
|
+
chunks.append(current)
|
|
188
|
+
return chunks
|
|
189
|
+
|
|
190
|
+
def _send(self, prepared: list[_Prepared]) -> None:
|
|
191
|
+
"""Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
|
|
192
|
+
|
|
193
|
+
Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
|
|
194
|
+
are counted (``failed``) and logged, not silently dropped (FR-003). The FIFO parameters
|
|
195
|
+
are attached only when the queue is FIFO, so a standard queue's entries are exactly
|
|
196
|
+
``Id`` + ``MessageBody`` (SPEC-016 FR-004).
|
|
197
|
+
"""
|
|
198
|
+
entries: list[dict[str, str]] = []
|
|
199
|
+
for i, item in enumerate(prepared):
|
|
200
|
+
entry = {"Id": str(i), "MessageBody": item.body}
|
|
201
|
+
if item.group_id is not None:
|
|
202
|
+
entry["MessageGroupId"] = item.group_id
|
|
203
|
+
if item.dedup_id is not None:
|
|
204
|
+
entry["MessageDeduplicationId"] = item.dedup_id
|
|
205
|
+
entries.append(entry)
|
|
206
|
+
for attempt in range(self.max_retries + 1):
|
|
207
|
+
response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
|
|
208
|
+
failed = response.get("Failed", [])
|
|
209
|
+
if not failed:
|
|
210
|
+
return
|
|
211
|
+
failed_ids = {entry["Id"] for entry in failed}
|
|
212
|
+
entries = [entry for entry in entries if entry["Id"] in failed_ids]
|
|
213
|
+
if attempt >= self.max_retries:
|
|
214
|
+
self.failed += len(entries)
|
|
215
|
+
sys.stderr.write(
|
|
216
|
+
f"log-foundry: {len(entries)} SQS message(s) still failing after "
|
|
217
|
+
f"{self.max_retries + 1} attempts; abandoned\n"
|
|
218
|
+
)
|
|
219
|
+
return
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
"""SQSSink — ship batches to an SQS queue (arch §8, §9.1, guide Phase 10).
|
|
2
|
-
|
|
3
|
-
SQS is the headline production path: a durable buffer that decouples the app from ELK
|
|
4
|
-
availability — events accumulate safely in the queue during downstream spikes/outages instead
|
|
5
|
-
of being lost or back-pressuring the app. A separate consumer indexes them into ELK (out of
|
|
6
|
-
scope here). Like every sink this receives *already-built* event dicts and knows nothing about
|
|
7
|
-
spans.
|
|
8
|
-
|
|
9
|
-
``boto3`` is an **optional** dependency (the ``aws`` extra): it is imported lazily inside the
|
|
10
|
-
sink, never at module top, so ``import log_foundry.sinks.sqs`` — and the whole library — stays
|
|
11
|
-
dependency-free unless an ``SQSSink`` is actually instantiated without an injected client.
|
|
12
|
-
|
|
13
|
-
The worker (SPEC-004) batches by count and time, but SQS has hard per-request limits, so this
|
|
14
|
-
sink re-chunks every incoming batch on both dimensions: ≤ 10 messages **and** ≤ 256 KB per
|
|
15
|
-
``send_message_batch``. Partial failures (the response ``Failed`` list) are retried; a single
|
|
16
|
-
event too large to ever fit is dropped with a warning rather than crashing the batch.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import json
|
|
22
|
-
import sys
|
|
23
|
-
from typing import Any
|
|
24
|
-
|
|
25
|
-
__all__ = ["SQSSink"]
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
class SQSSink:
|
|
29
|
-
"""A :class:`~log_foundry.sinks.base.Sink` that sends events to an SQS queue."""
|
|
30
|
-
|
|
31
|
-
MAX_BATCH = 10 # SQS SendMessageBatch hard limit: entries per request
|
|
32
|
-
MAX_BYTES = 256 * 1024 # SQS limit: 256 KB per request
|
|
33
|
-
|
|
34
|
-
def __init__(self, queue_url: str, client: Any = None, *, max_retries: int = 3) -> None:
|
|
35
|
-
if client is None:
|
|
36
|
-
import boto3 # type: ignore[import-not-found] # optional 'aws' extra
|
|
37
|
-
|
|
38
|
-
client = boto3.client("sqs")
|
|
39
|
-
self.queue_url = queue_url
|
|
40
|
-
self.client = client
|
|
41
|
-
self.max_retries = max_retries
|
|
42
|
-
self.dropped_oversized = 0 # events too large to ever fit one message
|
|
43
|
-
self.failed = 0 # entries still failing after the retry bound
|
|
44
|
-
|
|
45
|
-
def emit(self, batch: list[dict[str, object]]) -> None:
|
|
46
|
-
"""Re-chunk ``batch`` to SQS limits and send each chunk (FR-001, FR-002)."""
|
|
47
|
-
for chunk in self._chunks(batch):
|
|
48
|
-
self._send(chunk)
|
|
49
|
-
|
|
50
|
-
def close(self) -> None:
|
|
51
|
-
"""No-op: the sink buffers nothing internally (FR-005)."""
|
|
52
|
-
|
|
53
|
-
# -- internals ----------------------------------------------------------------------
|
|
54
|
-
|
|
55
|
-
def _chunks(self, batch: list[dict[str, object]]) -> list[list[str]]:
|
|
56
|
-
"""Split ``batch`` into sends of ≤ ``MAX_BATCH`` bodies and ≤ ``MAX_BYTES`` each.
|
|
57
|
-
|
|
58
|
-
Each event is serialized once with ``json.dumps``. An event whose serialized size
|
|
59
|
-
exceeds ``MAX_BYTES`` on its own can never fit a message, so it is dropped with a
|
|
60
|
-
counted warning (FR-004) instead of stalling the batch.
|
|
61
|
-
"""
|
|
62
|
-
chunks: list[list[str]] = []
|
|
63
|
-
current: list[str] = []
|
|
64
|
-
current_bytes = 0
|
|
65
|
-
for event in batch:
|
|
66
|
-
body = json.dumps(event)
|
|
67
|
-
size = len(body.encode("utf-8"))
|
|
68
|
-
if size > self.MAX_BYTES:
|
|
69
|
-
self.dropped_oversized += 1
|
|
70
|
-
sys.stderr.write(
|
|
71
|
-
f"log-foundry: dropped an event of {size} bytes exceeding the "
|
|
72
|
-
f"{self.MAX_BYTES}-byte SQS message limit\n"
|
|
73
|
-
)
|
|
74
|
-
continue
|
|
75
|
-
if current and (
|
|
76
|
-
len(current) >= self.MAX_BATCH or current_bytes + size > self.MAX_BYTES
|
|
77
|
-
):
|
|
78
|
-
chunks.append(current)
|
|
79
|
-
current = []
|
|
80
|
-
current_bytes = 0
|
|
81
|
-
current.append(body)
|
|
82
|
-
current_bytes += size
|
|
83
|
-
if current:
|
|
84
|
-
chunks.append(current)
|
|
85
|
-
return chunks
|
|
86
|
-
|
|
87
|
-
def _send(self, bodies: list[str]) -> None:
|
|
88
|
-
"""Send one valid chunk, retrying only the ``Failed`` entries with a bounded count.
|
|
89
|
-
|
|
90
|
-
Successfully-sent entries are never re-sent; entries still failing past ``max_retries``
|
|
91
|
-
are counted (``failed``) and logged, not silently dropped (FR-003).
|
|
92
|
-
"""
|
|
93
|
-
entries = [{"Id": str(i), "MessageBody": body} for i, body in enumerate(bodies)]
|
|
94
|
-
for attempt in range(self.max_retries + 1):
|
|
95
|
-
response = self.client.send_message_batch(QueueUrl=self.queue_url, Entries=entries)
|
|
96
|
-
failed = response.get("Failed", [])
|
|
97
|
-
if not failed:
|
|
98
|
-
return
|
|
99
|
-
failed_ids = {entry["Id"] for entry in failed}
|
|
100
|
-
entries = [entry for entry in entries if entry["Id"] in failed_ids]
|
|
101
|
-
if attempt >= self.max_retries:
|
|
102
|
-
self.failed += len(entries)
|
|
103
|
-
sys.stderr.write(
|
|
104
|
-
f"log-foundry: {len(entries)} SQS message(s) still failing after "
|
|
105
|
-
f"{self.max_retries + 1} attempts; abandoned\n"
|
|
106
|
-
)
|
|
107
|
-
return
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|