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.
Files changed (55) hide show
  1. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/PKG-INFO +10 -1
  2. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/README.md +9 -0
  3. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/pyproject.toml +1 -1
  4. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/__init__.py +12 -1
  5. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/api.py +19 -1
  6. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/decorator.py +140 -8
  7. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/worker.py +110 -14
  8. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/LICENSE +0 -0
  9. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/_diag.py +0 -0
  10. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/config.py +0 -0
  11. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/console.py +0 -0
  12. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/context.py +0 -0
  13. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/ids.py +0 -0
  14. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/model.py +0 -0
  15. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/py.typed +0 -0
  16. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sanitize.py +0 -0
  17. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/__init__.py +0 -0
  18. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_batch.py +0 -0
  19. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_chunk.py +0 -0
  20. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_retry.py +0 -0
  21. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_socket.py +0 -0
  22. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/_time.py +0 -0
  23. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/base.py +0 -0
  24. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/callback.py +0 -0
  25. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/clickhouse.py +0 -0
  26. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/datadog.py +0 -0
  27. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/elasticsearch.py +0 -0
  28. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/eventhubs.py +0 -0
  29. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/file.py +0 -0
  30. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/filtering.py +0 -0
  31. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/firehose.py +0 -0
  32. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/honeycomb.py +0 -0
  33. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/http.py +0 -0
  34. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/kafka.py +0 -0
  35. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/kinesis.py +0 -0
  36. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/logging_sink.py +0 -0
  37. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/logstash.py +0 -0
  38. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/loki.py +0 -0
  39. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/mongodb.py +0 -0
  40. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/multi.py +0 -0
  41. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/nats.py +0 -0
  42. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/newrelic.py +0 -0
  43. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/postgres.py +0 -0
  44. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/pubsub.py +0 -0
  45. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/rabbitmq.py +0 -0
  46. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/redis.py +0 -0
  47. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sentry.py +0 -0
  48. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sns.py +0 -0
  49. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/splunk.py +0 -0
  50. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sqlite.py +0 -0
  51. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/sqs.py +0 -0
  52. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/stdout.py +0 -0
  53. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/syslog.py +0 -0
  54. {log_foundry-0.10.2.dev30 → log_foundry-0.10.2.dev32}/src/log_foundry/sinks/transform.py +0 -0
  55. {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.dev30
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
@@ -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.dev30"
23
+ version = "0.10.2.dev32"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -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. Valid after :func:`shutdown`.
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().emit([event])
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, _atexit_registered
208
+ global _worker
206
209
  if _worker is None:
207
210
  with _worker_lock:
208
211
  if _worker is None:
209
- if not _atexit_registered:
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 if one was created, backing ``shutdown()``.
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
- return worker.health()
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 briefly counts the internal flush/shutdown markers alongside
80
- real submissions.
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 :meth:`Worker.shutdown` has been called. It describes an action the
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, so ``queued`` reads 0 rather than a
339
- stale marker. The same applies to ``stopped_reason``, since a caller finding a dead
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
- self._drain(pending)
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 the thread died can still enqueue a marker after
854
- this sweep, and that one waits out its timeout — then returns False, which is correct
855
- either way.
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 not None and item is not _SHUTDOWN:
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: