log-foundry 0.10.2.dev30__tar.gz → 0.10.2.dev32__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/PKG-INFO +10 -1
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/README.md +9 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/__init__.py +12 -1
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/api.py +19 -1
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/decorator.py +140 -8
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/worker.py +110 -14
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/LICENSE +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/transform.py +0 -0
- {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/util.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: log-foundry
|
|
3
|
-
Version: 0.10.2.
|
|
3
|
+
Version: 0.10.2.dev32
|
|
4
4
|
Summary: Generate logs for your console and JSON events for downstream consumption.
|
|
5
5
|
License-Expression: MIT
|
|
6
6
|
License-File: LICENSE
|
|
@@ -862,6 +862,15 @@ logging is doing the right thing; it is the *pair* — retired, and still being
|
|
|
862
862
|
means every log line since the shutdown has gone nowhere. That state used to read as perfectly
|
|
863
863
|
healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
|
|
864
864
|
|
|
865
|
+
`retired` is also the one field reported for a process that has **no worker at all**. A program
|
|
866
|
+
that only ever calls `info()`/`error()` outside a span emits synchronously and builds no background
|
|
867
|
+
worker, so every other field describes something that does not exist and reads zero. Its
|
|
868
|
+
`shutdown()` still closes the sink, exactly once and without starting a thread, and `retired` reads
|
|
869
|
+
`True` afterwards rather than staying vacuously `False`. `submitted_after_shutdown` stays `0` there
|
|
870
|
+
by design: a later level call is *refused* at the closed sink and announced on stderr — if the sink
|
|
871
|
+
guards its own post-close state — rather than queued where nothing will drain it, and those are not
|
|
872
|
+
the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
|
|
873
|
+
|
|
865
874
|
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
866
875
|
fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
|
|
867
876
|
(`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
|
|
@@ -826,6 +826,15 @@ logging is doing the right thing; it is the *pair* — retired, and still being
|
|
|
826
826
|
means every log line since the shutdown has gone nowhere. That state used to read as perfectly
|
|
827
827
|
healthy: `stopped_reason` is `None` after a clean shutdown, and the queue simply grows.
|
|
828
828
|
|
|
829
|
+
`retired` is also the one field reported for a process that has **no worker at all**. A program
|
|
830
|
+
that only ever calls `info()`/`error()` outside a span emits synchronously and builds no background
|
|
831
|
+
worker, so every other field describes something that does not exist and reads zero. Its
|
|
832
|
+
`shutdown()` still closes the sink, exactly once and without starting a thread, and `retired` reads
|
|
833
|
+
`True` afterwards rather than staying vacuously `False`. `submitted_after_shutdown` stays `0` there
|
|
834
|
+
by design: a later level call is *refused* at the closed sink and announced on stderr — if the sink
|
|
835
|
+
guards its own post-close state — rather than queued where nothing will drain it, and those are not
|
|
836
|
+
the same claim. A stateless sink such as the default `StdoutSink` still accepts it.
|
|
837
|
+
|
|
829
838
|
Read a snapshot by attribute (`h.dropped`), as above. `Health` is a `NamedTuple` and has gained
|
|
830
839
|
fields over time — a fourth (`stopped_reason`) in `v0.7.0`, and a fifth (`sink`) plus four more
|
|
831
840
|
(`retired`, `submitted_after_shutdown`, `incomplete_swaps`, `closing_sinks`) not yet in a tagged
|
|
@@ -98,7 +98,9 @@ def health() -> Health:
|
|
|
98
98
|
``failed`` is an upper bound on loss rather than a count of it, since a sink that raises
|
|
99
99
|
on total failure counts the attempt and hands the batch back for the worker to retry. A
|
|
100
100
|
process that has never logged has no worker, and asking does not create one — the
|
|
101
|
-
snapshot is simply zeroed
|
|
101
|
+
snapshot is simply zeroed, except for ``retired``, which stays truthful even for a
|
|
102
|
+
process that only ever logged outside a span and so built no worker at all (SPEC-031
|
|
103
|
+
FR-006). Valid after :func:`shutdown`.
|
|
102
104
|
|
|
103
105
|
Raises:
|
|
104
106
|
None.
|
|
@@ -122,6 +124,15 @@ def shutdown(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
|
122
124
|
non-zero ``submitted_after_shutdown``, and the first such submission writes one stderr line
|
|
123
125
|
(SPEC-030). That pair is the reading that catches the mistake above.
|
|
124
126
|
|
|
127
|
+
A process that only ever logged **outside** a span built no worker, and this used to be a
|
|
128
|
+
no-op there, leaving the sink open forever: every event lost on a sink whose ``close()`` is
|
|
129
|
+
what delivers them, the flush and the resource on a synchronous one. It now closes that
|
|
130
|
+
sink — exactly once, and without creating a worker to do it — and ``health().retired``
|
|
131
|
+
reads ``True`` afterwards rather than staying vacuously ``False`` (SPEC-031 FR-006). A
|
|
132
|
+
later level call reaches a closed sink, so a sink that guards its own post-close state
|
|
133
|
+
refuses it and one stderr line is written; a stateless one such as ``StdoutSink`` still
|
|
134
|
+
accepts it (SPEC-032).
|
|
135
|
+
|
|
125
136
|
Args:
|
|
126
137
|
timeout: Seconds bounding the wait for the background thread and, carved from the same
|
|
127
138
|
budget, a short grace for any sink still closing after a late ``configure(sink=...)``
|
|
@@ -6,6 +6,7 @@ from log_foundry import _diag, context
|
|
|
6
6
|
from log_foundry.config import _ensure_sink
|
|
7
7
|
from log_foundry.console import ConsoleWriter
|
|
8
8
|
from log_foundry.context import set_baggage
|
|
9
|
+
from log_foundry.decorator import _note_orphan_emit
|
|
9
10
|
from log_foundry.ids import new_span_id, new_trace_id
|
|
10
11
|
from log_foundry.model import Span, build_event
|
|
11
12
|
|
|
@@ -36,6 +37,21 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
|
|
|
36
37
|
logs"). It carried a comment saying the worker "will later" own it long after the
|
|
37
38
|
decision was made (SPEC-031 FR-003).
|
|
38
39
|
|
|
40
|
+
Because no worker is built here, nothing else in the library knows the sink was ever
|
|
41
|
+
written to — so this branch records it (SPEC-031 FR-006). That is what arms the exit-time
|
|
42
|
+
close for a process which only ever logs this way, and keying it on a sink this call is
|
|
43
|
+
about to write to, rather than on a *configured* sink, is deliberate: ``configure()``
|
|
44
|
+
materializes a ``StdoutSink`` whether or not anything is logged through it, and closing one
|
|
45
|
+
nothing was ever written to is cost with no benefit.
|
|
46
|
+
|
|
47
|
+
It is armed **before** the emit rather than after it, which matters for the sinks most
|
|
48
|
+
likely to need the close. SPEC-026 FR-001 makes a total failure raise, so an orphan-only
|
|
49
|
+
process against a dead syslog or HTTP destination raises on every call — and arming
|
|
50
|
+
afterwards would leave the socket that failure came from open forever, which is the leak
|
|
51
|
+
this FR exists to stop, in exactly the case that is leaking. A sink that raised is still a
|
|
52
|
+
sink that was written to. ``_ensure_sink`` is resolved first, so a sink that fails to
|
|
53
|
+
*construct* arms nothing: there is nothing to close.
|
|
54
|
+
|
|
39
55
|
The orphan branch is the one that reaches the sink on the caller's own thread, with no
|
|
40
56
|
worker between them to absorb a failure, so the whole branch is guarded (SPEC-025
|
|
41
57
|
FR-003) — ``_ensure_sink`` constructs the sink on first use, so a sink that fails to
|
|
@@ -72,7 +88,9 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
|
|
|
72
88
|
start_ts=0.0,
|
|
73
89
|
)
|
|
74
90
|
event = build_event(orphan, level, message, fields=fields, baggage=baggage)
|
|
75
|
-
_ensure_sink()
|
|
91
|
+
sink = _ensure_sink()
|
|
92
|
+
_note_orphan_emit()
|
|
93
|
+
sink.emit([event])
|
|
76
94
|
except Exception as exc:
|
|
77
95
|
_diag.absorbed("emitting an orphan log", exc, "the event was lost")
|
|
78
96
|
if echo and event is not None:
|
|
@@ -32,6 +32,9 @@ __all__ = ["continue_trace", "trace"]
|
|
|
32
32
|
_worker: Worker | None = None
|
|
33
33
|
_worker_lock = threading.Lock()
|
|
34
34
|
_atexit_registered = False
|
|
35
|
+
_orphan_close_owed = False
|
|
36
|
+
_orphan_sink_closed = False
|
|
37
|
+
_orphan_retired = False
|
|
35
38
|
|
|
36
39
|
F = TypeVar("F", bound=Callable[..., Any])
|
|
37
40
|
|
|
@@ -202,23 +205,127 @@ def _get_worker() -> Worker:
|
|
|
202
205
|
Raises:
|
|
203
206
|
Exception: Whatever constructing the sink or worker raises.
|
|
204
207
|
"""
|
|
205
|
-
global _worker
|
|
208
|
+
global _worker
|
|
206
209
|
if _worker is None:
|
|
207
210
|
with _worker_lock:
|
|
208
211
|
if _worker is None:
|
|
209
|
-
|
|
210
|
-
atexit.register(_shutdown_worker)
|
|
211
|
-
_atexit_registered = True
|
|
212
|
+
_register_exit_handler()
|
|
212
213
|
_worker = Worker(_ensure_sink())
|
|
213
214
|
return _worker
|
|
214
215
|
|
|
215
216
|
|
|
217
|
+
def _register_exit_handler() -> None:
|
|
218
|
+
"""Registers the one ``atexit`` handler that covers both delivery paths (SPEC-031 FR-006).
|
|
219
|
+
|
|
220
|
+
One registration, not two, and one flag guarding it. :func:`_shutdown_worker` handles the
|
|
221
|
+
worker path *and* the orphan path, so an orphan log arming this does not cost a later
|
|
222
|
+
``@trace`` its exit drain — which reusing a worker-only registration flag would. Two
|
|
223
|
+
handlers would be worse still: ``atexit`` runs LIFO, so the second would close a sink the
|
|
224
|
+
first had already closed. What is made once-only is the *close*, not the registration.
|
|
225
|
+
|
|
226
|
+
Callers hold ``_worker_lock``.
|
|
227
|
+
|
|
228
|
+
Args:
|
|
229
|
+
None.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
None.
|
|
233
|
+
|
|
234
|
+
Raises:
|
|
235
|
+
None.
|
|
236
|
+
"""
|
|
237
|
+
global _atexit_registered
|
|
238
|
+
if not _atexit_registered:
|
|
239
|
+
atexit.register(_shutdown_worker)
|
|
240
|
+
_atexit_registered = True
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _note_orphan_emit() -> None:
|
|
244
|
+
"""Records that a level call with no span reached the sink (SPEC-031 FR-006).
|
|
245
|
+
|
|
246
|
+
This is what arms the exit-time close, and it is deliberately keyed on an event having
|
|
247
|
+
*landed* rather than on a sink existing: ``configure()`` runs ``_ensure_sink()``
|
|
248
|
+
unconditionally, so a bare ``configure(service=…)`` has already built a ``StdoutSink``,
|
|
249
|
+
and keying on that would close a sink nothing was ever written to.
|
|
250
|
+
|
|
251
|
+
The unlocked read is the fast path on a per-call route — the flag is written once and
|
|
252
|
+
never cleared, so a racing reader either sees it set or takes the lock and finds it set
|
|
253
|
+
there.
|
|
254
|
+
|
|
255
|
+
Args:
|
|
256
|
+
None.
|
|
257
|
+
|
|
258
|
+
Returns:
|
|
259
|
+
None.
|
|
260
|
+
|
|
261
|
+
Raises:
|
|
262
|
+
None.
|
|
263
|
+
"""
|
|
264
|
+
global _orphan_close_owed
|
|
265
|
+
if _orphan_close_owed:
|
|
266
|
+
return
|
|
267
|
+
with _worker_lock:
|
|
268
|
+
if not _orphan_close_owed:
|
|
269
|
+
_register_exit_handler()
|
|
270
|
+
_orphan_close_owed = True
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _close_orphan_sink() -> None:
|
|
274
|
+
"""Closes a sink only the orphan path ever wrote to, once (SPEC-031 FR-006).
|
|
275
|
+
|
|
276
|
+
A process that never opens a span builds no worker, so nothing owned the sink's close and
|
|
277
|
+
nothing performed it: on a locally-buffering sink every event died in the client's batch,
|
|
278
|
+
on a synchronous one the flush and the resource were lost, and ``health()`` read all-clear
|
|
279
|
+
because every field it carries describes a worker that does not exist.
|
|
280
|
+
|
|
281
|
+
A live worker owns the close instead, and this returns — that is what makes a mixed
|
|
282
|
+
process exactly one ``close()`` in either order. It also inherits the worker's reasons for
|
|
283
|
+
*not* closing: an expired :meth:`Worker.shutdown` leaves the sink open because the drain
|
|
284
|
+
thread may still be inside ``emit``.
|
|
285
|
+
|
|
286
|
+
That check is read **under** ``_worker_lock``, not ahead of it, because :func:`_get_worker`
|
|
287
|
+
assigns ``_worker`` while holding that same lock. Unlocked, a ``shutdown()`` racing a first
|
|
288
|
+
``@trace`` could read ``None``, block behind the worker's construction, and then close the
|
|
289
|
+
sink underneath the worker that had just captured it — reproduced with an injected
|
|
290
|
+
preemption point, the way SPEC-028 demonstrates the races that need one.
|
|
291
|
+
|
|
292
|
+
The once-only flag is set ahead of the close, as ``Worker.shutdown``'s is: a second
|
|
293
|
+
``close()`` on a sink that partially released its resources is worse than an unclosed one.
|
|
294
|
+
|
|
295
|
+
Args:
|
|
296
|
+
None.
|
|
297
|
+
|
|
298
|
+
Returns:
|
|
299
|
+
None.
|
|
300
|
+
|
|
301
|
+
Raises:
|
|
302
|
+
None. This runs from ``atexit``, where an escaping exception makes CPython print a
|
|
303
|
+
traceback carrying the message arch §6 keeps out of anything the library says about
|
|
304
|
+
itself. ``Exception``, never ``BaseException`` (SPEC-025 FR-004).
|
|
305
|
+
"""
|
|
306
|
+
global _orphan_sink_closed
|
|
307
|
+
with _worker_lock:
|
|
308
|
+
if _worker is not None or not _orphan_close_owed or _orphan_sink_closed:
|
|
309
|
+
return
|
|
310
|
+
_orphan_sink_closed = True
|
|
311
|
+
try:
|
|
312
|
+
_ensure_sink().close()
|
|
313
|
+
except Exception as exc:
|
|
314
|
+
_diag.absorbed("closing the sink", exc, "it may still hold its resources")
|
|
315
|
+
|
|
316
|
+
|
|
216
317
|
def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
217
|
-
"""Drains and closes the process worker
|
|
318
|
+
"""Drains and closes the process worker, or closes an orphan-only sink, backing ``shutdown()``.
|
|
218
319
|
|
|
219
320
|
The ``atexit`` registration binds this function, so the exit path gets the bounded form
|
|
220
321
|
and its default (SPEC-027 FR-004) — an unbounded join in an ``atexit`` handler is a
|
|
221
|
-
process that will not exit. Idempotent.
|
|
322
|
+
process that will not exit. Idempotent on both paths.
|
|
323
|
+
|
|
324
|
+
``_orphan_retired`` is set unconditionally and read only when there is no worker, which is
|
|
325
|
+
what makes ``health().retired`` truthful for a process that shut down without ever
|
|
326
|
+
building one (SPEC-031 FR-006). No worker is created here to answer it: standing up a
|
|
327
|
+
thread at exit to prove there is nothing to drain is pure cost, the same refusal
|
|
328
|
+
:func:`_swap_sink` and :func:`_flush_worker` already make.
|
|
222
329
|
|
|
223
330
|
Args:
|
|
224
331
|
timeout: Seconds to wait for the drain, or ``None`` to wait indefinitely.
|
|
@@ -229,8 +336,12 @@ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
|
|
|
229
336
|
Raises:
|
|
230
337
|
None.
|
|
231
338
|
"""
|
|
339
|
+
global _orphan_retired
|
|
340
|
+
_orphan_retired = True
|
|
232
341
|
if _worker is not None:
|
|
233
342
|
_worker.shutdown(timeout)
|
|
343
|
+
return
|
|
344
|
+
_close_orphan_sink()
|
|
234
345
|
|
|
235
346
|
|
|
236
347
|
def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> None:
|
|
@@ -301,6 +412,24 @@ def _worker_health() -> Health:
|
|
|
301
412
|
created has not died, which is why SPEC-019 reports the terminal failure as a reason
|
|
302
413
|
rather than an ``alive`` flag.
|
|
303
414
|
|
|
415
|
+
``retired`` is the one field synthesized rather than zeroed (SPEC-031 FR-006). It records
|
|
416
|
+
an action the caller took, not a state of the worker, so it stays true in a process that
|
|
417
|
+
called ``shutdown()`` without ever building one — where it was previously vacuous, and the
|
|
418
|
+
whole snapshot read all-clear over a sink that had just been closed.
|
|
419
|
+
``submitted_after_shutdown`` is deliberately **not** synthesized alongside it: SPEC-030
|
|
420
|
+
defines that count as submissions queued where nothing will drain them, and a later orphan
|
|
421
|
+
log is refused at the closed sink and announced instead. The two are not the same claim.
|
|
422
|
+
|
|
423
|
+
The synthesis also survives a worker built *after* that shutdown, which is why it is an
|
|
424
|
+
``or`` rather than a fallback. An orphan-only ``shutdown()`` leaves ``_worker`` unset, so a
|
|
425
|
+
later ``@trace`` constructs a fresh worker whose own ``retired`` is ``False`` — and reading
|
|
426
|
+
that alone would say the process was never shut down, contradicting this function's own
|
|
427
|
+
guarantee one call earlier. The events that worker carries are not lost silently: against a
|
|
428
|
+
sink that guards its post-close state they raise and land in ``failed_batches`` (measured),
|
|
429
|
+
and against one that releases nothing on ``close()`` they genuinely still deliver. So the
|
|
430
|
+
detection is ``failed_batches`` there rather than SPEC-030's ``retired`` +
|
|
431
|
+
``submitted_after_shutdown`` pair, which stays the signal for the path it was built for.
|
|
432
|
+
|
|
304
433
|
Args:
|
|
305
434
|
None.
|
|
306
435
|
|
|
@@ -312,8 +441,11 @@ def _worker_health() -> Health:
|
|
|
312
441
|
"""
|
|
313
442
|
worker = _worker
|
|
314
443
|
if worker is None:
|
|
315
|
-
return Health(queued=0, dropped=0, failed_batches=0)
|
|
316
|
-
|
|
444
|
+
return Health(queued=0, dropped=0, failed_batches=0, retired=_orphan_retired)
|
|
445
|
+
health = worker.health()
|
|
446
|
+
if _orphan_retired and not health.retired:
|
|
447
|
+
return health._replace(retired=True)
|
|
448
|
+
return health
|
|
317
449
|
|
|
318
450
|
|
|
319
451
|
def _flush(span: Span) -> None:
|
|
@@ -76,8 +76,10 @@ class Health(NamedTuple):
|
|
|
76
76
|
|
|
77
77
|
Attributes:
|
|
78
78
|
queued: Submissions currently buffered. Approximate by nature: it is read without
|
|
79
|
-
stopping the world, and
|
|
80
|
-
|
|
79
|
+
stopping the world, and counts the internal flush/shutdown markers alongside real
|
|
80
|
+
submissions — normally only in passing, but a ``flush()`` marker stranded by racing
|
|
81
|
+
``shutdown()`` is answered and then counted for the life of the process, since
|
|
82
|
+
``Queue`` offers no way to remove one specific item.
|
|
81
83
|
dropped: Submissions discarded because the queue was full (backpressure).
|
|
82
84
|
failed_batches: Batches abandoned after the retry budget was spent.
|
|
83
85
|
stopped_reason: The exception type name that terminated the drain thread, or ``None`` if
|
|
@@ -90,12 +92,15 @@ class Health(NamedTuple):
|
|
|
90
92
|
the sink reports nothing (SPEC-026 FR-003). Nested rather than folded into the
|
|
91
93
|
integers above because they count different things: ``dropped`` here is backpressure
|
|
92
94
|
at this queue, ``dropped`` on the sink is an event that never reached the wire.
|
|
93
|
-
retired: Whether
|
|
95
|
+
retired: Whether ``shutdown()`` has been called. It describes an action the
|
|
94
96
|
caller took, not a failure the library detected, which is why it is a boolean where
|
|
95
97
|
``stopped_reason`` is a string — SPEC-019 rejected an ``alive`` flag because it would
|
|
96
98
|
read ``False`` for a process that never logged, and that objection does not apply to a
|
|
97
99
|
field which is simply ``False`` until someone calls ``shutdown()`` (SPEC-030 FR-001).
|
|
98
100
|
On its own it is not a fault: a process that shuts down and stops logging is correct.
|
|
101
|
+
It is the one field ``decorator._worker_health`` synthesizes rather than zeroing, so
|
|
102
|
+
that a process which only ever logged outside a span — and therefore has no worker at
|
|
103
|
+
all — still reports its own shutdown truthfully (SPEC-031 FR-006).
|
|
99
104
|
submitted_after_shutdown: Submissions accepted after ``shutdown()`` and queued where
|
|
100
105
|
nothing will drain them. Non-zero alongside ``retired`` is the signature of the
|
|
101
106
|
serverless mistake — ``shutdown()`` called per invocation on a warm container, so the
|
|
@@ -224,6 +229,7 @@ class Worker:
|
|
|
224
229
|
self._closers: list[threading.Thread] = []
|
|
225
230
|
self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
|
|
226
231
|
self._stop = threading.Event()
|
|
232
|
+
self._drain_finished = threading.Event()
|
|
227
233
|
self._shutdown_done = False
|
|
228
234
|
self._sink_closed = False
|
|
229
235
|
self._lock = threading.Lock()
|
|
@@ -335,8 +341,11 @@ class Worker:
|
|
|
335
341
|
"""Snapshots the delivery counters (SPEC-017 FR-005, SPEC-019 FR-003).
|
|
336
342
|
|
|
337
343
|
This stays valid after :meth:`shutdown`: the counters are plain integers that outlive
|
|
338
|
-
the thread, and the final drain consumes the queue
|
|
339
|
-
|
|
344
|
+
the thread, and the final drain consumes the queue. ``queued`` therefore reads 0 for a
|
|
345
|
+
worker nothing logged to afterwards — but not always: submissions accepted after the
|
|
346
|
+
shutdown stay queued on purpose (SPEC-030), and a ``flush()`` marker stranded by
|
|
347
|
+
racing it is answered and then counted, as ``Health.queued`` records. The same applies
|
|
348
|
+
to ``stopped_reason``, since a caller finding a dead
|
|
340
349
|
worker will usually call ``shutdown()`` next. Reading it after a shutdown is in fact
|
|
341
350
|
the point of ``retired`` and ``submitted_after_shutdown`` (SPEC-030 FR-001), which
|
|
342
351
|
report a state only a retired worker can be in.
|
|
@@ -405,6 +414,24 @@ class Worker:
|
|
|
405
414
|
queue ``put_nowait`` would skip the flush and return as though it had succeeded, the
|
|
406
415
|
one outcome a flush must never produce silently.
|
|
407
416
|
|
|
417
|
+
The drain's completion is re-checked **after** the put, and that second look is what
|
|
418
|
+
makes a ``timeout=None`` call safe. The checks above can both pass microseconds before
|
|
419
|
+
the drain finishes, leaving this marker queued behind something that will never read it
|
|
420
|
+
— a bounded caller then waits out its timeout, which SPEC-021 accepts as correct either
|
|
421
|
+
way, but an unbounded one waits forever.
|
|
422
|
+
|
|
423
|
+
It tests ``_drain_finished`` and not only ``is_alive()``, because the two are not the
|
|
424
|
+
same instant and the gap between them is where the hang survives: the terminal-failure
|
|
425
|
+
path sweeps for markers and *then* returns, so a marker queued after that sweep sits
|
|
426
|
+
behind a thread still reading as alive. The flag is set **before** the sweep, and a
|
|
427
|
+
``put`` and the sweep's snapshot both take the queue's own mutex, so a marker either
|
|
428
|
+
lands before the snapshot and is answered, or lands after it and finds the flag set.
|
|
429
|
+
|
|
430
|
+
Reporting is by the **marker**, never by the check alone. A drain that answered this
|
|
431
|
+
marker and then exited has delivered, and saying otherwise would be a false failure —
|
|
432
|
+
one ``swap_sink`` reads as an unconfirmed drain, counting ``incomplete_swaps``, leaving
|
|
433
|
+
the previous sink open and writing a loss line for a swap that in fact completed.
|
|
434
|
+
|
|
408
435
|
Args:
|
|
409
436
|
timeout: Seconds bounding the whole call — one deadline shared by the put and the
|
|
410
437
|
wait, so the two cannot add up to twice the timeout. ``None`` waits indefinitely.
|
|
@@ -430,6 +457,8 @@ class Worker:
|
|
|
430
457
|
self._queue.put(marker, timeout=timeout)
|
|
431
458
|
except queue.Full:
|
|
432
459
|
return False
|
|
460
|
+
if self._drain_finished.is_set() or not self._thread.is_alive():
|
|
461
|
+
return marker.event.is_set() and marker.delivered
|
|
433
462
|
remaining = None if deadline is None else max(0.0, deadline - time.monotonic())
|
|
434
463
|
if not marker.event.wait(remaining):
|
|
435
464
|
return False
|
|
@@ -635,6 +664,36 @@ class Worker:
|
|
|
635
664
|
``close()`` twice on a sink that may have partially released its resources; what
|
|
636
665
|
SPEC-025 FR-004 changed is that the failure is announced rather than swallowed.
|
|
637
666
|
|
|
667
|
+
**The sentinel is queued before ``_stop`` is set, and while the drain loop is running
|
|
668
|
+
that order is what makes it impossible to strand.** Both ways of leaving the loop —
|
|
669
|
+
taking the sentinel, or seeing ``_stop`` — can only happen once it is already in the
|
|
670
|
+
queue, so either that ``get`` consumes it or :meth:`_final_drain` does. The reverse
|
|
671
|
+
order left a window in which the loop read ``_stop``, exited, and finished its final
|
|
672
|
+
drain before the sentinel landed. It never lost an event, but left ``health().queued``
|
|
673
|
+
reading 1 for the life of the process. The rate is load-dependent and not worth
|
|
674
|
+
quoting as a property — rare when idle, and repeatedly reproduced between roughly one
|
|
675
|
+
shutdown in 14 and one in 50 with spinner threads and a tightened switch interval.
|
|
676
|
+
|
|
677
|
+
Paying for that order needs :meth:`_drain` to break on the sentinel rather than loop, or
|
|
678
|
+
a thread taking it before ``_stop`` was set would block for another ``flush_interval``
|
|
679
|
+
— measured stalling low single-digit percentages of shutdowns under load for the entire
|
|
680
|
+
budget, latching a ``stopped_reason`` of ``"ShutdownTimeout"``, which is far worse than
|
|
681
|
+
the cosmetic problem being fixed.
|
|
682
|
+
|
|
683
|
+
The premise is the loop, so the put is skipped once the drain has stopped reading. A
|
|
684
|
+
drain that died terminally (SPEC-019) is not coming back for a wake-up, and queueing one
|
|
685
|
+
for it would strand it permanently — reintroducing the symptom on the one path the
|
|
686
|
+
ordering cannot reach. The gate is ``_drain_finished`` rather than ``is_alive()``,
|
|
687
|
+
because they are not the same instant: the thread is still alive throughout
|
|
688
|
+
:meth:`_terminal_failure`, which writes to stderr and can block on a slow reader, and a
|
|
689
|
+
liveness test would queue a sentinel through that whole window. The flag is set before
|
|
690
|
+
that call, so this one does not.
|
|
691
|
+
|
|
692
|
+
:meth:`_release_waiters` runs on the way out for the sibling case the ordering cannot
|
|
693
|
+
reach: a ``flush()`` that passed its liveness check microseconds before the thread
|
|
694
|
+
finished can still queue a marker nothing will answer, and with ``timeout=None`` that
|
|
695
|
+
caller waits forever rather than merely too long.
|
|
696
|
+
|
|
638
697
|
The worker does not come back, and :meth:`submit` keeps accepting afterwards — so a
|
|
639
698
|
caller that logs again queues events nothing will drain. That is reported rather than
|
|
640
699
|
prevented, through ``retired`` and ``submitted_after_shutdown`` (SPEC-030 FR-001) and
|
|
@@ -661,11 +720,12 @@ class Worker:
|
|
|
661
720
|
self._close_if_owed()
|
|
662
721
|
self._join_closers(None if deadline is None else max(0.0, deadline - time.monotonic()))
|
|
663
722
|
return
|
|
723
|
+
if not self._drain_finished.is_set():
|
|
724
|
+
try:
|
|
725
|
+
self._queue.put_nowait(_SHUTDOWN)
|
|
726
|
+
except queue.Full:
|
|
727
|
+
pass
|
|
664
728
|
self._stop.set()
|
|
665
|
-
try:
|
|
666
|
-
self._queue.put_nowait(_SHUTDOWN)
|
|
667
|
-
except queue.Full:
|
|
668
|
-
pass
|
|
669
729
|
self._thread.join(timeout)
|
|
670
730
|
if self._thread.is_alive():
|
|
671
731
|
queued = self._queued_or_unknown()
|
|
@@ -679,6 +739,7 @@ class Worker:
|
|
|
679
739
|
f"because the worker thread is still using it",
|
|
680
740
|
)
|
|
681
741
|
return
|
|
742
|
+
self._release_waiters()
|
|
682
743
|
self._close_if_owed()
|
|
683
744
|
self._join_closers(None if deadline is None else max(0.0, deadline - time.monotonic()))
|
|
684
745
|
|
|
@@ -823,6 +884,18 @@ class Worker:
|
|
|
823
884
|
``BaseException``. It records and exits; looping onward past a ``KeyboardInterrupt``
|
|
824
885
|
would be a worse failure than the one this prevents.
|
|
825
886
|
|
|
887
|
+
The two ``finally`` blocks are nested rather than merged, and the order they impose is
|
|
888
|
+
load-bearing twice over. ``_drain_finished`` is set the instant the loop stops reading
|
|
889
|
+
the queue — *before* :meth:`_terminal_failure`, which writes to stderr and can block on
|
|
890
|
+
a slow reader — so a ``shutdown()`` arriving during that window sees a drain that is
|
|
891
|
+
already finished and declines to queue a sentinel nothing would consume. And it is set
|
|
892
|
+
before :meth:`_release_waiters`, so a marker either lands ahead of that sweep's snapshot
|
|
893
|
+
and is answered by it, or lands behind it and finds the flag set; both take the queue's
|
|
894
|
+
own mutex, which is what leaves no gap between the two. Sweeping here rather than only
|
|
895
|
+
in :meth:`Worker.shutdown` is what covers the paths ``shutdown`` never reaches — a
|
|
896
|
+
terminal failure, and a bounded shutdown that expired while this thread was still
|
|
897
|
+
inside an emit.
|
|
898
|
+
|
|
826
899
|
Args:
|
|
827
900
|
None.
|
|
828
901
|
|
|
@@ -834,9 +907,13 @@ class Worker:
|
|
|
834
907
|
"""
|
|
835
908
|
pending: list[list[dict[str, object]]] = []
|
|
836
909
|
try:
|
|
837
|
-
|
|
910
|
+
try:
|
|
911
|
+
self._drain(pending)
|
|
912
|
+
finally:
|
|
913
|
+
self._drain_finished.set()
|
|
838
914
|
except BaseException as exc:
|
|
839
915
|
self._terminal_failure(exc, len(pending))
|
|
916
|
+
finally:
|
|
840
917
|
self._release_waiters()
|
|
841
918
|
|
|
842
919
|
def _release_waiters(self) -> None:
|
|
@@ -849,10 +926,20 @@ class Worker:
|
|
|
849
926
|
``health().queued`` and the terminal line report; each keeps its pessimistic
|
|
850
927
|
``delivered``, which is the truth here.
|
|
851
928
|
|
|
929
|
+
It is called from two places. The terminal-failure path is the original one. The clean
|
|
930
|
+
:meth:`shutdown` path was added because that same enqueue-after-the-drain race happens
|
|
931
|
+
there too, and hurts more: measured stranding a marker in 13 of 400 shutdowns raced
|
|
932
|
+
against a ``flush()`` under load, where the caller sat out its whole timeout — and
|
|
933
|
+
``flush(timeout=None)``, which the API documents as supported, waits forever rather
|
|
934
|
+
than too long. The marker keeps its pessimistic ``delivered``, which is the honest
|
|
935
|
+
answer: the drain that would have carried it is gone.
|
|
936
|
+
|
|
852
937
|
One residual race, stated rather than papered over: a ``flush()`` that passed its
|
|
853
|
-
liveness check microseconds before
|
|
854
|
-
|
|
855
|
-
|
|
938
|
+
liveness check microseconds before *this* sweep can still enqueue a marker after it,
|
|
939
|
+
and that one waits out its timeout — then returns False, which is correct either way.
|
|
940
|
+
A marker left queued is also still counted by ``health().queued``, which describes
|
|
941
|
+
submissions; removing it would mean deleting a specific item, which ``Queue`` has no
|
|
942
|
+
public way to do, and the read above is the access ``architecture.md`` §13 sanctions.
|
|
856
943
|
|
|
857
944
|
The reliance on ``queue.Queue``'s private ``mutex`` and ``queue`` is deliberate and is
|
|
858
945
|
recorded in ``architecture.md`` §13 Known Constraints (SPEC-031 FR-005): there is no
|
|
@@ -925,6 +1012,13 @@ class Worker:
|
|
|
925
1012
|
advanced even when idle: otherwise the timeout collapses to zero and ``get`` busy-spins
|
|
926
1013
|
a core.
|
|
927
1014
|
|
|
1015
|
+
The shutdown sentinel **breaks** rather than falling through to the loop condition.
|
|
1016
|
+
:meth:`shutdown` queues it before setting ``_stop``, so a thread that takes it may find
|
|
1017
|
+
``_stop`` still clear; continuing would re-enter ``get`` and block for another
|
|
1018
|
+
``flush_interval``, which is a slow shutdown rather than a prompt one. Leaving
|
|
1019
|
+
immediately is safe because the only thing after the loop is :meth:`_final_drain`, which
|
|
1020
|
+
collects whatever is still queued — the sentinel is a wake-up, never a fence.
|
|
1021
|
+
|
|
928
1022
|
Args:
|
|
929
1023
|
pending: The accumulator owned by :meth:`_run`, which reports its size on a terminal
|
|
930
1024
|
failure. It is mutated in place rather than rebound, so that count is accurate.
|
|
@@ -950,7 +1044,9 @@ class Worker:
|
|
|
950
1044
|
last_flush = time.monotonic()
|
|
951
1045
|
item.event.set()
|
|
952
1046
|
continue
|
|
953
|
-
if item is
|
|
1047
|
+
if item is _SHUTDOWN:
|
|
1048
|
+
break
|
|
1049
|
+
if item is not None:
|
|
954
1050
|
pending.append(cast("list[dict[str, object]]", item))
|
|
955
1051
|
now = time.monotonic()
|
|
956
1052
|
if len(pending) >= self.batch_size or now - last_flush >= self.flush_interval:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
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
|