log-foundry 0.7.1.dev3__tar.gz → 0.7.2.dev2__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.7.1.dev3 → log_foundry-0.7.2.dev2}/PKG-INFO +25 -7
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/README.md +24 -6
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/pyproject.toml +1 -1
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/__init__.py +10 -6
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/worker.py +138 -23
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/LICENSE +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/api.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/config.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/console.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/context.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/model.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.7.1.dev3 → log_foundry-0.7.2.dev2}/src/log_foundry/sinks/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: log-foundry
|
|
3
|
-
Version: 0.7.
|
|
3
|
+
Version: 0.7.2.dev2
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -681,9 +681,19 @@ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
|
|
|
681
681
|
explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
|
|
682
682
|
end of a short script. It is idempotent.
|
|
683
683
|
|
|
684
|
-
`flush(timeout=5.0)` returns `True` when
|
|
685
|
-
|
|
686
|
-
|
|
684
|
+
`flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
|
|
685
|
+
drain it forces reached the sink, and so did anything else the worker emitted while it waited its
|
|
686
|
+
turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
|
|
687
|
+
any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
|
|
688
|
+
drain took place.
|
|
689
|
+
|
|
690
|
+
The window starts when you call it. A batch abandoned *before* that is deliberately not its
|
|
691
|
+
business: the loss is already counted in `health().failed_batches` and reported on stderr, and
|
|
692
|
+
folding it in would make every later `flush()` in the process report a failure it did not incur.
|
|
693
|
+
So `flush()` answers "did the logs I am waiting on get out", and `health()` answers "has anything
|
|
694
|
+
been lost at all" — **check both**, as the handler below does.
|
|
695
|
+
|
|
696
|
+
It never raises — a logging call must not be the reason your function fails. Passing
|
|
687
697
|
`timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
|
|
688
698
|
|
|
689
699
|
#### Serverless / short-lived processes
|
|
@@ -715,9 +725,17 @@ def handler(event, context):
|
|
|
715
725
|
try:
|
|
716
726
|
return do_work(event)
|
|
717
727
|
finally:
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
728
|
+
# In `finally`: the failed invocation is the one worth logging. NEVER shutdown() here —
|
|
729
|
+
# the worker does not come back, and every later invocation on this warm container
|
|
730
|
+
# would log nothing.
|
|
731
|
+
drained = lf.flush()
|
|
732
|
+
h = lf.health()
|
|
733
|
+
if not drained or h.failed_batches or h.dropped or h.stopped_reason:
|
|
734
|
+
# `drained` covers this invocation's tail; the counters cover anything the worker
|
|
735
|
+
# lost earlier — a batch its own interval trigger already gave up on, for instance.
|
|
736
|
+
# Emitting this through your platform's own logger keeps it outside the pipeline
|
|
737
|
+
# that just failed.
|
|
738
|
+
print(f"log-foundry: undelivered logs ({drained=}, {h=})")
|
|
721
739
|
```
|
|
722
740
|
|
|
723
741
|
By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
|
|
@@ -645,9 +645,19 @@ lf.shutdown() # drain, close the sink, and stop for good; blocks until drained
|
|
|
645
645
|
explicitly when you need to be certain the tail reached the sink before a fast exit, e.g. at the
|
|
646
646
|
end of a short script. It is idempotent.
|
|
647
647
|
|
|
648
|
-
`flush(timeout=5.0)` returns `True` when
|
|
649
|
-
|
|
650
|
-
|
|
648
|
+
`flush(timeout=5.0)` returns `True` when **nothing was lost while the call was outstanding** — the
|
|
649
|
+
drain it forces reached the sink, and so did anything else the worker emitted while it waited its
|
|
650
|
+
turn. It returns `False` on timeout, when the worker was already shut down or has died, and when
|
|
651
|
+
any batch was abandoned inside that window. A `True` is evidence of delivery, not merely that a
|
|
652
|
+
drain took place.
|
|
653
|
+
|
|
654
|
+
The window starts when you call it. A batch abandoned *before* that is deliberately not its
|
|
655
|
+
business: the loss is already counted in `health().failed_batches` and reported on stderr, and
|
|
656
|
+
folding it in would make every later `flush()` in the process report a failure it did not incur.
|
|
657
|
+
So `flush()` answers "did the logs I am waiting on get out", and `health()` answers "has anything
|
|
658
|
+
been lost at all" — **check both**, as the handler below does.
|
|
659
|
+
|
|
660
|
+
It never raises — a logging call must not be the reason your function fails. Passing
|
|
651
661
|
`timeout=None` waits indefinitely, which is unsafe anywhere with an execution deadline.
|
|
652
662
|
|
|
653
663
|
#### Serverless / short-lived processes
|
|
@@ -679,9 +689,17 @@ def handler(event, context):
|
|
|
679
689
|
try:
|
|
680
690
|
return do_work(event)
|
|
681
691
|
finally:
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
692
|
+
# In `finally`: the failed invocation is the one worth logging. NEVER shutdown() here —
|
|
693
|
+
# the worker does not come back, and every later invocation on this warm container
|
|
694
|
+
# would log nothing.
|
|
695
|
+
drained = lf.flush()
|
|
696
|
+
h = lf.health()
|
|
697
|
+
if not drained or h.failed_batches or h.dropped or h.stopped_reason:
|
|
698
|
+
# `drained` covers this invocation's tail; the counters cover anything the worker
|
|
699
|
+
# lost earlier — a batch its own interval trigger already gave up on, for instance.
|
|
700
|
+
# Emitting this through your platform's own logger keeps it outside the pipeline
|
|
701
|
+
# that just failed.
|
|
702
|
+
print(f"log-foundry: undelivered logs ({drained=}, {h=})")
|
|
685
703
|
```
|
|
686
704
|
|
|
687
705
|
By default each invocation is its own trace, so N invocations produce N `trace_id`s. To join
|
|
@@ -30,10 +30,11 @@ except PackageNotFoundError: # running from a source tree that isn't installed
|
|
|
30
30
|
def flush(timeout: float | None = 5.0) -> bool:
|
|
31
31
|
"""Drain buffered events through the sink without closing it.
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
the caller cannot have meant those.) Unlike
|
|
36
|
-
alive and the sink stays open, so logging
|
|
33
|
+
``True`` means the events submitted before this call reached the sink; ``False`` means the
|
|
34
|
+
drain carrying them was abandoned, or never completed. (Events submitted concurrently by
|
|
35
|
+
another thread may or may not be included — the caller cannot have meant those.) Unlike
|
|
36
|
+
:func:`shutdown` the background worker stays alive and the sink stays open, so logging
|
|
37
|
+
continues normally afterwards.
|
|
37
38
|
|
|
38
39
|
This is the drain for a process that is *frozen* rather than exited — an AWS Lambda
|
|
39
40
|
handler must drain before it returns, but will be invoked again on the same warm
|
|
@@ -42,8 +43,11 @@ def flush(timeout: float | None = 5.0) -> bool:
|
|
|
42
43
|
``timeout=None`` waits indefinitely, which is unsafe in any environment with an execution
|
|
43
44
|
deadline — it converts "some logs were lost" into "the invocation timed out".
|
|
44
45
|
|
|
45
|
-
Returns ``False`` if the drain did not complete within ``timeout``,
|
|
46
|
-
already been shut down
|
|
46
|
+
Returns ``False`` if the drain did not complete within ``timeout``, if the worker has
|
|
47
|
+
already been shut down or has died, or if a batch was abandoned while this call was
|
|
48
|
+
outstanding (SPEC-021 FR-001) — a ``True`` means the events were delivered, not merely that
|
|
49
|
+
a drain took place. A batch lost *before* the call is outside this call's window and belongs
|
|
50
|
+
to :func:`health`, whose ``failed_batches`` is the cumulative record. Never raises.
|
|
47
51
|
"""
|
|
48
52
|
from log_foundry.decorator import _flush_worker
|
|
49
53
|
|
|
@@ -74,12 +74,41 @@ class _FlushMarker:
|
|
|
74
74
|
|
|
75
75
|
Like ``_SHUTDOWN`` it is never emitted — but unlike ``_SHUTDOWN`` it carries state, so it is
|
|
76
76
|
a class rather than a bare sentinel object.
|
|
77
|
+
|
|
78
|
+
``delivered`` carries the drain's *outcome* back to the waiter, not merely the fact that the
|
|
79
|
+
marker was reached (SPEC-021 FR-001). It is written by the drain thread before ``event.set()``
|
|
80
|
+
and read by the waiter after ``event.wait()`` returns, so the ``Event`` supplies the ordering
|
|
81
|
+
and no further lock is needed.
|
|
82
|
+
|
|
83
|
+
The outcome is computed from ``seen_failures`` — ``Worker.failed_batches`` as it stood when
|
|
84
|
+
this marker was created, on the *caller's* thread — against the same counter when the marker is
|
|
85
|
+
answered. Equal means nothing was abandoned while this flush was outstanding, which covers both
|
|
86
|
+
the batch the marker forces and any batch another flush or a batching trigger emitted in the
|
|
87
|
+
meantime. So every flush *outstanding* when a batch is abandoned reports it, including the ones
|
|
88
|
+
whose own emit found nothing left to do because another marker's emit had just cleared and lost
|
|
89
|
+
it — that case was the original false success.
|
|
90
|
+
|
|
91
|
+
Two limits, both deliberate. A batch abandoned *before* the call is not in scope: that loss is
|
|
92
|
+
already in ``failed_batches`` and on stderr, and folding it in would make every later empty
|
|
93
|
+
flush report a failure it did not incur. It follows that two *concurrent* flushes need not
|
|
94
|
+
agree — one stamped before the abandonment and one after are the outstanding case and the
|
|
95
|
+
already-lost case respectively, and the race between them decides which is which. Reporting a
|
|
96
|
+
past loss and not letting a past loss stick are the same property read in opposite directions;
|
|
97
|
+
FR-001's "an empty drain is a successful one" chooses. ``health().failed_batches`` is what
|
|
98
|
+
reports a loss regardless of when anyone asked.
|
|
99
|
+
|
|
100
|
+
``delivered`` starts ``False``: every path that answers a marker assigns it explicitly, so the
|
|
101
|
+
default is read only when the drain thread died without computing an answer, where "I could not
|
|
102
|
+
establish that this was delivered" is the honest reading. (The spec's Data Model sketched the
|
|
103
|
+
default as ``True``, before the concurrent-flush case moved the empty-drain answer off it.)
|
|
77
104
|
"""
|
|
78
105
|
|
|
79
|
-
__slots__ = ("event",)
|
|
106
|
+
__slots__ = ("delivered", "event", "seen_failures")
|
|
80
107
|
|
|
81
|
-
def __init__(self) -> None:
|
|
108
|
+
def __init__(self, seen_failures: int) -> None:
|
|
82
109
|
self.event = threading.Event()
|
|
110
|
+
self.delivered = False
|
|
111
|
+
self.seen_failures = seen_failures
|
|
83
112
|
|
|
84
113
|
|
|
85
114
|
class Worker:
|
|
@@ -162,10 +191,24 @@ class Worker:
|
|
|
162
191
|
def flush(self, timeout: float | None = 5.0) -> bool:
|
|
163
192
|
"""Drain everything submitted before this call through the sink, without stopping.
|
|
164
193
|
|
|
165
|
-
Returns ``True`` once the worker has
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
194
|
+
Returns ``True`` once the worker has *delivered* them — ``False`` on timeout, on a worker
|
|
195
|
+
already shut down or dead, on a queue too full to accept the marker, and when the drain
|
|
196
|
+
carrying those events was abandoned after exhausting retries (SPEC-021 FR-001). That last
|
|
197
|
+
case used to return ``True``: the drain had run, so the marker was answered regardless of
|
|
198
|
+
what came of the emit. It is a false success exactly where ``flush()`` matters most — a
|
|
199
|
+
serverless handler draining before the environment freezes has the return value as its
|
|
200
|
+
only evidence the tail of the queue survived.
|
|
201
|
+
|
|
202
|
+
The precise claim is *nothing was abandoned while this call was outstanding*: the batch
|
|
203
|
+
this flush forces, and any batch another flush or a batching trigger emitted while its
|
|
204
|
+
marker waited its turn. A batch abandoned **before** the call is not in scope — that loss
|
|
205
|
+
is already in ``failed_batches`` and on stderr, and counting it here would make every
|
|
206
|
+
later empty flush report a failure it did not incur. ``health()`` is the cumulative
|
|
207
|
+
record; this is a verdict on one drain.
|
|
208
|
+
|
|
209
|
+
Unlike :meth:`shutdown` the thread keeps running, the sink is **not** closed, and the
|
|
210
|
+
once-only shutdown flag is untouched, so logging continues normally afterwards
|
|
211
|
+
(SPEC-013 FR-002).
|
|
169
212
|
"""
|
|
170
213
|
with self._lock:
|
|
171
214
|
if self._shutdown_done:
|
|
@@ -175,7 +218,12 @@ class Worker:
|
|
|
175
218
|
return False
|
|
176
219
|
if not self._thread.is_alive():
|
|
177
220
|
return False
|
|
178
|
-
|
|
221
|
+
with self._lock:
|
|
222
|
+
# Stamped here, on the caller's thread, *before* the marker joins the queue: it is the
|
|
223
|
+
# baseline the drain thread compares against, so anything abandoned from this moment
|
|
224
|
+
# on is attributed to this flush. Reading it after the put would race the very emit
|
|
225
|
+
# the flush is about to force.
|
|
226
|
+
marker = _FlushMarker(self.failed_batches)
|
|
179
227
|
# One deadline shared by the put and the wait: a caller asked for a bound on the whole
|
|
180
228
|
# call, not on each half of it, so the two cannot add up to 2 * timeout.
|
|
181
229
|
deadline = None if timeout is None else time.monotonic() + timeout
|
|
@@ -187,7 +235,9 @@ class Worker:
|
|
|
187
235
|
except queue.Full:
|
|
188
236
|
return False
|
|
189
237
|
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
190
|
-
|
|
238
|
+
if not marker.event.wait(remaining):
|
|
239
|
+
return False # timed out: the drain never happened, so it delivered nothing.
|
|
240
|
+
return marker.delivered
|
|
191
241
|
|
|
192
242
|
def shutdown(self) -> None:
|
|
193
243
|
"""Stop the thread, drain + emit everything queued, then ``close()`` the sink.
|
|
@@ -224,6 +274,31 @@ class Worker:
|
|
|
224
274
|
self._drain(pending)
|
|
225
275
|
except BaseException as exc: # deliberately broad — see the docstring
|
|
226
276
|
self._terminal_failure(exc, len(pending))
|
|
277
|
+
self._release_waiters()
|
|
278
|
+
|
|
279
|
+
def _release_waiters(self) -> None:
|
|
280
|
+
"""Answer every ``flush()`` marker still queued, so no caller waits out its timeout.
|
|
281
|
+
|
|
282
|
+
A ``BaseException`` from the main loop skips ``_final_drain`` entirely, which is where
|
|
283
|
+
queued markers are normally answered — leaving a waiter to sit for its full ``timeout``
|
|
284
|
+
on a thread that is never coming back. FR-001 requires the waiter to be released on every
|
|
285
|
+
path, and this is the last one.
|
|
286
|
+
|
|
287
|
+
The markers are *read* out of the queue rather than consumed: the queued event-lists are
|
|
288
|
+
the evidence ``health().queued`` and the terminal line report, and answering a waiter must
|
|
289
|
+
not erase it. Each keeps its pessimistic ``delivered``, which is the truth here.
|
|
290
|
+
|
|
291
|
+
Residual race, stated rather than papered over: a ``flush()`` that passed its liveness
|
|
292
|
+
check microseconds before the thread died can still enqueue a marker after this sweep, and
|
|
293
|
+
that one waits out its timeout — and then returns ``False``, which is correct either way.
|
|
294
|
+
"""
|
|
295
|
+
try:
|
|
296
|
+
with self._queue.mutex: # a snapshot, so a marker cannot be missed mid-iteration
|
|
297
|
+
markers = [i for i in self._queue.queue if isinstance(i, _FlushMarker)]
|
|
298
|
+
for marker in markers:
|
|
299
|
+
marker.event.set()
|
|
300
|
+
except Exception: # this runs *after* the record and the stderr line; neither may be lost
|
|
301
|
+
pass
|
|
227
302
|
|
|
228
303
|
def _terminal_failure(self, exc: BaseException, undrained: int) -> None:
|
|
229
304
|
"""Record the drain loop's terminal exit, then announce it (FR-001, FR-002).
|
|
@@ -265,12 +340,13 @@ class Worker:
|
|
|
265
340
|
# marker must never fall through to the append below, where it would be treated
|
|
266
341
|
# as a list of events and handed to sink.emit, killing this thread.
|
|
267
342
|
try:
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
pending.clear()
|
|
343
|
+
self._emit_pending(pending)
|
|
344
|
+
item.delivered = self._nothing_lost_since(item)
|
|
271
345
|
finally:
|
|
272
346
|
# Signal even if the emit died, so a waiter is released rather than left to
|
|
273
|
-
# wait out its timeout on a thread that is no longer running.
|
|
347
|
+
# wait out its timeout on a thread that is no longer running. A failing flush
|
|
348
|
+
# therefore returns False promptly rather than at the caller's timeout, and
|
|
349
|
+
# an emit that died leaves the marker's pessimistic default standing.
|
|
274
350
|
last_flush = time.monotonic()
|
|
275
351
|
item.event.set()
|
|
276
352
|
continue
|
|
@@ -278,15 +354,38 @@ class Worker:
|
|
|
278
354
|
pending.append(cast("list[dict[str, object]]", item))
|
|
279
355
|
now = time.monotonic()
|
|
280
356
|
if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
|
|
281
|
-
|
|
282
|
-
self._emit(pending)
|
|
283
|
-
pending.clear()
|
|
357
|
+
self._emit_pending(pending)
|
|
284
358
|
# Advance the window even when idle (pending empty). Otherwise last_flush never
|
|
285
359
|
# moves while the queue is empty, timeout collapses to 0.0, and get(timeout=0.0)
|
|
286
360
|
# busy-spins a core. Resetting it lets the next get() block a full interval.
|
|
287
361
|
last_flush = now
|
|
288
362
|
self._final_drain(pending)
|
|
289
363
|
|
|
364
|
+
def _emit_pending(self, pending: list[list[dict[str, object]]]) -> None:
|
|
365
|
+
"""Emit ``pending`` if there is any, then clear it. The two always go together."""
|
|
366
|
+
if pending:
|
|
367
|
+
self._emit(pending)
|
|
368
|
+
pending.clear()
|
|
369
|
+
|
|
370
|
+
def _nothing_lost_since(self, marker: _FlushMarker) -> bool:
|
|
371
|
+
"""Whether any batch was abandoned while ``marker`` was outstanding (SPEC-021 FR-001).
|
|
372
|
+
|
|
373
|
+
``failed_batches`` moves exactly once per abandoned batch, so comparing it against the
|
|
374
|
+
marker's stamp answers "was anything lost while this flush was in flight" — which is the
|
|
375
|
+
question ``flush()`` asks, and a stronger one than "did *my* emit succeed". A marker whose
|
|
376
|
+
own emit found nothing pending still reports the loss if another flush's emit, or a
|
|
377
|
+
batching trigger's, abandoned a batch while it waited its turn.
|
|
378
|
+
|
|
379
|
+
Deliberately *not* a running "has anything ever failed" flag. That would make every empty
|
|
380
|
+
flush after a single bad batch report a failure it did not incur, contradicting the rule
|
|
381
|
+
that an empty drain is a successful one.
|
|
382
|
+
|
|
383
|
+
Called on the drain thread; the lock is for the counter's other readers, and is held only
|
|
384
|
+
for the read (``shutdown()`` releases it before ``join()``, so it cannot deadlock).
|
|
385
|
+
"""
|
|
386
|
+
with self._lock:
|
|
387
|
+
return self.failed_batches == marker.seen_failures
|
|
388
|
+
|
|
290
389
|
def _final_drain(self, pending: list[list[dict[str, object]]]) -> None:
|
|
291
390
|
"""On stop, pull anything still queued and emit the tail as one final batch."""
|
|
292
391
|
markers: list[_FlushMarker] = []
|
|
@@ -298,15 +397,21 @@ class Worker:
|
|
|
298
397
|
if isinstance(item, _FlushMarker):
|
|
299
398
|
# This guard is a second copy of _drain's and needs the same exclusion. Markers
|
|
300
399
|
# are answered *after* the final emit below, so a flush() that raced shutdown()
|
|
301
|
-
#
|
|
400
|
+
# is answered by the drain that carried its events — with that drain's outcome.
|
|
302
401
|
markers.append(item)
|
|
303
402
|
continue
|
|
304
403
|
if item is not None and item is not _SHUTDOWN:
|
|
305
404
|
pending.append(cast("list[dict[str, object]]", item))
|
|
306
|
-
|
|
307
|
-
self.
|
|
308
|
-
|
|
309
|
-
|
|
405
|
+
try:
|
|
406
|
+
self._emit_pending(pending)
|
|
407
|
+
for marker in markers:
|
|
408
|
+
marker.delivered = self._nothing_lost_since(marker)
|
|
409
|
+
finally:
|
|
410
|
+
# In a ``finally`` so a ``BaseException`` from the final emit cannot strand a waiter
|
|
411
|
+
# for its whole timeout — the markers are released carrying their pessimistic
|
|
412
|
+
# default, which is the truth about a batch that died with this thread.
|
|
413
|
+
for marker in markers:
|
|
414
|
+
marker.event.set()
|
|
310
415
|
|
|
311
416
|
def _emit(self, event_lists: list[list[dict[str, object]]]) -> None:
|
|
312
417
|
"""Flatten queued per-span event-lists into one batch and emit, retrying with backoff.
|
|
@@ -314,23 +419,33 @@ class Worker:
|
|
|
314
419
|
A failing ``sink.emit`` is retried up to ``max_retries`` times; past that the batch is
|
|
315
420
|
abandoned with a counted warning and draining continues, so a broken sink never crashes
|
|
316
421
|
the worker thread or the app (FR-002, FR-003).
|
|
422
|
+
|
|
423
|
+
The outcome is not returned: ``failed_batches`` already moves exactly once per abandoned
|
|
424
|
+
batch, and that counter is what a waiting ``flush()`` is compared against (SPEC-021
|
|
425
|
+
FR-001), so a second channel for the same fact could only disagree with it.
|
|
426
|
+
|
|
427
|
+
``max_retries`` is floored at zero so the loop always makes at least one attempt. A
|
|
428
|
+
negative value otherwise skipped the emit entirely and discarded the batch with no
|
|
429
|
+
attempt, no counter and nothing on stderr — a silent loss of exactly the kind this arc of
|
|
430
|
+
specs exists to remove, reachable only by misconfiguration but reachable.
|
|
317
431
|
"""
|
|
318
432
|
batch = [event for events in event_lists for event in events]
|
|
319
433
|
if not batch:
|
|
320
434
|
return
|
|
321
|
-
|
|
435
|
+
retries = max(self.max_retries, 0)
|
|
436
|
+
for attempt in range(retries + 1):
|
|
322
437
|
try:
|
|
323
438
|
self.sink.emit(batch)
|
|
324
439
|
return
|
|
325
440
|
except Exception: # any sink failure must not kill the worker thread
|
|
326
|
-
if attempt >=
|
|
441
|
+
if attempt >= retries:
|
|
327
442
|
# Under the lock so a concurrent health() sees a coherent snapshot rather
|
|
328
443
|
# than a half-updated pair. No deadlock: shutdown() releases before join().
|
|
329
444
|
with self._lock:
|
|
330
445
|
self.failed_batches += 1
|
|
331
446
|
sys.stderr.write(
|
|
332
447
|
f"log-foundry: abandoned a batch of {len(batch)} event(s) after "
|
|
333
|
-
f"{
|
|
448
|
+
f"{retries + 1} failed emit attempts\n"
|
|
334
449
|
)
|
|
335
450
|
return
|
|
336
451
|
# Backoff between attempts; _stop.wait returns at once during shutdown, so a
|
|
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
|