log-foundry 0.10.2.dev33__tar.gz → 0.10.2.dev34__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 (56) hide show
  1. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/pyproject.toml +1 -1
  3. log_foundry-0.10.2.dev34/src/log_foundry/_lifecycle.py +175 -0
  4. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/api.py +1 -1
  5. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/config.py +16 -8
  6. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/decorator.py +240 -41
  7. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/worker.py +37 -69
  8. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/LICENSE +0 -0
  9. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/README.md +0 -0
  10. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/__init__.py +0 -0
  11. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/_diag.py +0 -0
  12. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/console.py +0 -0
  13. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/context.py +0 -0
  14. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/ids.py +0 -0
  15. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/model.py +0 -0
  16. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/py.typed +0 -0
  17. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sanitize.py +0 -0
  18. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/__init__.py +0 -0
  19. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/_batch.py +0 -0
  20. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/_chunk.py +0 -0
  21. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/_retry.py +0 -0
  22. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/_socket.py +0 -0
  23. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/_time.py +0 -0
  24. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/base.py +0 -0
  25. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/callback.py +0 -0
  26. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/clickhouse.py +0 -0
  27. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/datadog.py +0 -0
  28. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/elasticsearch.py +0 -0
  29. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/eventhubs.py +0 -0
  30. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/file.py +0 -0
  31. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/filtering.py +0 -0
  32. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/firehose.py +0 -0
  33. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/honeycomb.py +0 -0
  34. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/http.py +0 -0
  35. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/kafka.py +0 -0
  36. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/kinesis.py +0 -0
  37. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/logging_sink.py +0 -0
  38. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/logstash.py +0 -0
  39. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/loki.py +0 -0
  40. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/mongodb.py +0 -0
  41. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/multi.py +0 -0
  42. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/nats.py +0 -0
  43. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/newrelic.py +0 -0
  44. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/postgres.py +0 -0
  45. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/pubsub.py +0 -0
  46. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/rabbitmq.py +0 -0
  47. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/redis.py +0 -0
  48. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/sentry.py +0 -0
  49. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/sns.py +0 -0
  50. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/splunk.py +0 -0
  51. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/sqlite.py +0 -0
  52. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/sqs.py +0 -0
  53. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/stdout.py +0 -0
  54. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/syslog.py +0 -0
  55. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/src/log_foundry/sinks/transform.py +0 -0
  56. {log_foundry-0.10.2.dev33 → log_foundry-0.10.2.dev34}/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.dev33
3
+ Version: 0.10.2.dev34
4
4
  Summary: Generate logs for your console and JSON events for downstream consumption.
5
5
  License-Expression: MIT
6
6
  License-File: LICENSE
@@ -20,7 +20,7 @@ dependencies = [
20
20
  ]
21
21
 
22
22
  # Optional features. Install with: pip install log-foundry[aws]
23
- version = "0.10.2.dev33"
23
+ version = "0.10.2.dev34"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -0,0 +1,175 @@
1
+ """Sink-lifecycle facilities shared by both delivery paths (SPEC-033 FR-005)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import threading
6
+ import time
7
+ from typing import TYPE_CHECKING
8
+
9
+ from log_foundry import _diag
10
+
11
+ if TYPE_CHECKING:
12
+ from log_foundry.sinks.base import Sink
13
+
14
+ DEFAULT_CLOSER_GRACE = 2.0
15
+ """Seconds a shutdown gives an outstanding swapped-out close to finish.
16
+
17
+ Deliberately much smaller than the shutdown budget it is carved from. This is a last chance for
18
+ a close that is *nearly* done, not a second full attempt: it already had the swap's whole budget
19
+ (``DEFAULT_SWAP_TIMEOUT``) before ``shutdown`` was ever called, so one still running here is far
20
+ more likely stuck than slow, and every second spent on it is a second the process does not exit.
21
+ """
22
+
23
+ _closers: list[threading.Thread] = []
24
+ _closers_lock = threading.Lock()
25
+
26
+
27
+ def close_detached(sink: Sink) -> threading.Thread | None:
28
+ """Starts a daemon close of a sink no longer being delivered to (SPEC-030 FR-003).
29
+
30
+ The thread is returned rather than joined, so a caller holding a lock can start under it and
31
+ wait after releasing it — ``decorator._swap_sink`` mutates its records under the process-wide
32
+ ``_worker_lock`` and must not hold that across a wait of the swap's whole budget (SPEC-033
33
+ FR-002). Callers that hold no lock join it immediately and are equivalent to the single call
34
+ this replaced.
35
+
36
+ The thread is a **daemon**, and it is :func:`join_closers` that makes that safe rather than
37
+ merely available. A non-daemon thread was tried and is worse on its own: CPython joins
38
+ non-daemon threads *before* running ``atexit``, so one hung close stops the exit drain from
39
+ ever running and loses everything buffered in the **live** sink. A daemon alone is worse in
40
+ the opposite case: a close that is slow but *succeeding* is killed at exit, losing whatever
41
+ it was flushing.
42
+
43
+ Args:
44
+ sink: The sink that was swapped out.
45
+
46
+ Returns:
47
+ The started thread, or ``None`` when the platform would not give the process another one.
48
+
49
+ Raises:
50
+ None. ``Thread.start`` raises when the process is out of threads, and a swap that cannot
51
+ spawn one must leave the sink open and say so rather than fall back to an inline close —
52
+ the fallback would reintroduce the unbounded wait this exists to remove, in the one
53
+ situation where the process is already under resource pressure.
54
+ """
55
+ closer = threading.Thread(
56
+ target=_close_guarded,
57
+ args=(sink,),
58
+ name="log-foundry-sink-close",
59
+ daemon=True,
60
+ )
61
+ try:
62
+ closer.start()
63
+ except Exception as exc:
64
+ _diag.absorbed(
65
+ "starting the thread that closes a swapped-out sink",
66
+ exc,
67
+ "it is left open and may still hold its resources",
68
+ )
69
+ return None
70
+ with _closers_lock:
71
+ _closers[:] = [old for old in _closers if old.is_alive()]
72
+ _closers.append(closer)
73
+ return closer
74
+
75
+
76
+ def _close_guarded(sink: Sink) -> None:
77
+ """Closes a swapped-out sink on its own thread, absorbing a failure.
78
+
79
+ The guard is what makes the thread safe to leave unattended: an exception escaping here
80
+ would reach CPython's thread bootstrap, which prints a full traceback carrying the
81
+ exception's message — the user data arch §6 keeps out of anything the library says about
82
+ itself.
83
+
84
+ Args:
85
+ sink: The sink to close.
86
+
87
+ Returns:
88
+ None.
89
+
90
+ Raises:
91
+ None.
92
+ """
93
+ try:
94
+ sink.close()
95
+ except Exception as exc:
96
+ _diag.absorbed("closing a swapped-out sink", exc, "it may still hold its resources")
97
+
98
+
99
+ def join_closers(timeout: float | None) -> None:
100
+ """Gives outstanding swapped-out closes their last chance before the process exits.
101
+
102
+ **The cap is the mechanism.** The wait is the smaller of :data:`DEFAULT_CLOSER_GRACE` and
103
+ what remains of the shutdown's own budget: capped so a stuck close cannot hold a process at
104
+ exit for the whole shutdown budget, and carved from that budget so it cannot extend it either.
105
+
106
+ The registry is process-global rather than per-worker because a close started before any
107
+ worker existed must still be counted and still be granted this grace (SPEC-033 FR-005).
108
+
109
+ Args:
110
+ timeout: Seconds remaining in the shutdown's budget, further capped by
111
+ :data:`DEFAULT_CLOSER_GRACE` and shared across every outstanding close. ``None`` takes
112
+ the cap rather than waiting indefinitely — an unbounded shutdown is a caller's choice
113
+ about draining events, not a licence for a stuck close to hold the exit.
114
+
115
+ Returns:
116
+ None.
117
+
118
+ Raises:
119
+ None. A join on a thread that has already finished is a no-op, and one that has not is
120
+ abandoned at the deadline — which is the daemon's contract, not a failure.
121
+ """
122
+ with _closers_lock:
123
+ closers = [closer for closer in _closers if closer.is_alive()]
124
+ _closers[:] = closers
125
+ grace = DEFAULT_CLOSER_GRACE if timeout is None else min(timeout, DEFAULT_CLOSER_GRACE)
126
+ deadline = time.monotonic() + grace
127
+ for closer in closers:
128
+ closer.join(max(0.0, deadline - time.monotonic()))
129
+
130
+
131
+ def closing_count() -> int:
132
+ """Counts the swapped-out closes running at this instant, backing ``Health.closing_sinks``.
133
+
134
+ A live fact rather than an inference from a timeout: an expired join reports nothing, since
135
+ a slow close and a stuck one cannot be told apart at that moment, so this gauge is what an
136
+ operator reads instead. It falls as well as rises.
137
+
138
+ Args:
139
+ None.
140
+
141
+ Returns:
142
+ The number of closer threads still alive.
143
+
144
+ Raises:
145
+ None.
146
+ """
147
+ with _closers_lock:
148
+ _closers[:] = [closer for closer in _closers if closer.is_alive()]
149
+ return len(_closers)
150
+
151
+
152
+ def offer_stop_signal(sink: Sink, stop: threading.Event) -> None:
153
+ """Gives a sink an interruptible-wait signal, if it advertises somewhere to put one.
154
+
155
+ The dependency stays one-way (SPEC-027 FR-002): ``sinks`` must not import ``worker``, so the
156
+ holder of the event pushes rather than the sink pulling. It is probed with ``hasattr``, the
157
+ same optional-protocol shape SPEC-026 uses for ``losses()`` — a sink without the attribute
158
+ simply never gets one and backs off uninterruptibly, exactly as before.
159
+
160
+ Args:
161
+ sink: The sink to offer the signal to.
162
+ stop: The event that is set when delivery should stop waiting.
163
+
164
+ Returns:
165
+ None.
166
+
167
+ Raises:
168
+ None. A sink whose ``stop_signal`` is a read-only property, or whose ``__setattr__``
169
+ objects, loses interruptibility rather than preventing the caller from proceeding.
170
+ """
171
+ try:
172
+ if hasattr(sink, "stop_signal"):
173
+ sink.stop_signal = stop
174
+ except Exception as exc:
175
+ _diag.absorbed("handing the sink its stop signal", exc, "its backoff stays uninterruptible")
@@ -89,7 +89,7 @@ def _log(level: str, message: str, echo: bool, fields: dict[str, object]) -> Non
89
89
  )
90
90
  event = build_event(orphan, level, message, fields=fields, baggage=baggage)
91
91
  sink = _ensure_sink()
92
- _note_orphan_emit()
92
+ _note_orphan_emit(sink)
93
93
  sink.emit([event])
94
94
  except Exception as exc:
95
95
  _diag.absorbed("emitting an orphan log", exc, "the event was lost")
@@ -77,14 +77,22 @@ def configure(
77
77
  assigned, so a rejected call leaves the config exactly as it found it.
78
78
 
79
79
  A ``sink=`` passed after logging has already started is the one argument that needs more
80
- than an assignment, because the background worker captured its sink when it was built
81
- (arch §7). It **swaps the live delivery target**: everything submitted so far is drained to
82
- the previous sink, that sink is closed, and subsequent events go to the new one. The drains
83
- are bounded, and a swap whose drain could not be confirmed leaves the previous sink open and
84
- records ``health().incomplete_swaps`` (SPEC-030 FR-003) so "repeated calls compose rather
85
- than reset" holds for the sink too, at the cost of one bounded wait. Passing the sink that
86
- is already live is a no-op: no drain, no close. The previous sink is closed and must not be
87
- handed back to a later call.
80
+ than an assignment, because whatever is delivering captured its sink before the call
81
+ (arch §7). It **swaps the live delivery target**: the previous sink is closed and subsequent
82
+ events go to the new one, so "repeated calls compose rather than reset" holds for the sink
83
+ too, at the cost of one bounded wait. Passing the sink that is already live is a no-op: no
84
+ drain, no close. The previous sink is closed and must not be handed back to a later call —
85
+ doing so closes it twice.
86
+
87
+ **What the swap promises differs by delivery path**, because the two have different work to
88
+ do. With a background worker — anything using ``@trace`` — everything submitted so far is
89
+ drained to the previous sink before it is closed, and a drain that could not be confirmed
90
+ leaves that sink open and records ``health().incomplete_swaps`` (SPEC-030 FR-003). A process
91
+ that has only ever logged outside a span has no worker and nothing buffered: those events
92
+ were emitted synchronously and have already returned, so the handoff is the close alone
93
+ (SPEC-033 FR-002). There is no drain to confirm there and ``incomplete_swaps`` stays at zero
94
+ by design; the close is still bounded, and ``health().closing_sinks`` still reports one that
95
+ has not come back.
88
96
 
89
97
  The bound covers the whole call, the close included. ``Sink.close`` takes no timeout, so that
90
98
  close runs on its own daemon thread and is joined for what is left of the budget: a
@@ -10,7 +10,7 @@ from collections.abc import Callable
10
10
  from time import monotonic
11
11
  from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
12
12
 
13
- from log_foundry import _diag, context
13
+ from log_foundry import _diag, _lifecycle, context
14
14
  from log_foundry.config import _ensure_sink
15
15
  from log_foundry.ids import (
16
16
  is_valid_span_id,
@@ -32,8 +32,30 @@ __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
35
+ _orphan_sink: Sink | None = None
36
+ """The sink the orphan path owns the close of, or ``None`` when nothing is owed (SPEC-033 FR-001).
37
+
38
+ Written only under ``_worker_lock``; read without it on the emit hot path, where the read is
39
+ **stale, never invalid** — a reference read is atomic, a stale mismatch self-corrects under the
40
+ lock, and a stale match is reachable only when an emit races a close, which is the lifecycle error
41
+ SPEC-030 documents rather than a new one.
42
+ """
43
+ _orphan_closed_sink: Sink | None = None
44
+ """The most recently closed orphan-owned sink, refused re-arming (SPEC-033 FR-001).
45
+
46
+ An identity rather than SPEC-031's boolean, which made the close once per *process* and so left a
47
+ sink configured after ``shutdown()`` unclosed forever. It is a single slot: handing back a sink
48
+ already swapped out re-admits it, which arch §13 records rather than fixes, since tracking every
49
+ sink ever closed would pin them all against collection to fix what the worker path does not fix
50
+ either.
51
+ """
52
+ _orphan_stop = threading.Event()
53
+ """The stop signal handed to a sink no live worker owns (SPEC-033 FR-004).
54
+
55
+ Replaced with a fresh event whenever it is already set, because an ``Event`` is set once and never
56
+ cleared and ``sinks/_retry.wait`` returns immediately on a set one — a sink still holding the
57
+ shutdown's event has every backoff collapsed to zero.
58
+ """
37
59
  _orphan_retired = False
38
60
 
39
61
  F = TypeVar("F", bound=Callable[..., Any])
@@ -205,12 +227,13 @@ def _get_worker() -> Worker:
205
227
  Raises:
206
228
  Exception: Whatever constructing the sink or worker raises.
207
229
  """
208
- global _worker
230
+ global _worker, _orphan_sink
209
231
  if _worker is None:
210
232
  with _worker_lock:
211
233
  if _worker is None:
212
234
  _register_exit_handler()
213
235
  _worker = Worker(_ensure_sink())
236
+ _orphan_sink = None
214
237
  return _worker
215
238
 
216
239
 
@@ -240,20 +263,31 @@ def _register_exit_handler() -> None:
240
263
  _atexit_registered = True
241
264
 
242
265
 
243
- def _note_orphan_emit() -> None:
244
- """Records that a level call with no span reached the sink (SPEC-031 FR-006).
266
+ def _note_orphan_emit(sink: Sink) -> None:
267
+ """Records which sink a level call with no span reached (SPEC-031 FR-006, SPEC-033 FR-001).
245
268
 
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.
269
+ This arms the exit-time close, and it is deliberately keyed on an event having *landed*
270
+ rather than on a sink existing: ``configure()`` runs ``_ensure_sink()`` unconditionally, so a
271
+ bare ``configure(service=…)`` has already built a ``StdoutSink``, and keying on that would
272
+ close a sink nothing was ever written to.
250
273
 
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.
274
+ It records the sink **object**, not a flag. ``configure()`` assigns ``_config.sink`` before
275
+ it calls the swap, so by the time anything could close the previous sink the config no longer
276
+ names it — a boolean cannot say which one is owed. A sink already recorded as closed is
277
+ refused re-arming, which is what stops a post-``shutdown()`` emit against a closed sink
278
+ causing a second ``close()`` on it.
279
+
280
+ The unlocked fast path is a stale read, never an invalid one: a reference read is atomic, a
281
+ stale mismatch simply takes the lock and re-checks, and a stale match is reachable only when
282
+ an emit races a close — the lifecycle error SPEC-030 documents rather than one introduced
283
+ here.
284
+
285
+ The stop-signal offer is keyed on the *sink* rather than on the record (SPEC-033 FR-004), so
286
+ it also reaches a sink that is latched closed and still being emitted to; an arming-keyed
287
+ offer would leave that one holding the shutdown's set event and backing off not at all.
254
288
 
255
289
  Args:
256
- None.
290
+ sink: The sink this call is about to emit to.
257
291
 
258
292
  Returns:
259
293
  None.
@@ -261,13 +295,85 @@ def _note_orphan_emit() -> None:
261
295
  Raises:
262
296
  None.
263
297
  """
264
- global _orphan_close_owed
265
- if _orphan_close_owed:
298
+ global _orphan_sink
299
+ if (sink is _orphan_sink or sink is _orphan_closed_sink) and not _orphan_stop.is_set():
266
300
  return
267
301
  with _worker_lock:
268
- if not _orphan_close_owed:
269
- _register_exit_handler()
270
- _orphan_close_owed = True
302
+ _offer_orphan_signal(sink)
303
+ if sink is _orphan_sink or sink is _orphan_closed_sink:
304
+ return
305
+ _register_exit_handler()
306
+ _orphan_sink = sink
307
+
308
+
309
+ def _live_worker() -> Worker | None:
310
+ """Returns the process worker only while it is still delivering (SPEC-033 FR-002).
311
+
312
+ Three guards ask who owns a sink, and two of them mean a *live* owner. A retired worker holds
313
+ its sink forever — :meth:`Worker.swap_sink` returns early once shut down — so keying on a
314
+ worker merely existing hands the swap to something that will do nothing with it, and the sink
315
+ adopted afterwards is closed by no one: measured, ``configure(A)`` → ``@trace`` →
316
+ ``shutdown()`` → ``configure(B)`` → ``info()`` → ``configure(C)`` left B unclosed with its
317
+ event undelivered and every counter clean, which is the failure shape this spec exists to
318
+ close.
319
+
320
+ :func:`_close_orphan_sink` deliberately does **not** use this: there a retired worker's
321
+ ownership is exactly what must make it decline, since an expired shutdown leaves the drain
322
+ thread possibly still inside that sink's ``emit``.
323
+
324
+ Args:
325
+ None.
326
+
327
+ Returns:
328
+ The worker while it is still delivering, or ``None`` when there is none or it has retired.
329
+
330
+ Raises:
331
+ None.
332
+ """
333
+ worker = _worker
334
+ return None if worker is None or worker.retired else worker
335
+
336
+
337
+ def _offer_orphan_signal(sink: Sink) -> None:
338
+ """Gives a sink no live worker owns an unset stop signal (SPEC-033 FR-004).
339
+
340
+ An orphan-only process never receives one otherwise — ``Worker._offer_stop_signal`` is the
341
+ only caller and there is no worker — so SPEC-027's guarantee that a shutdown cuts a backoff
342
+ short is false on this path, and the inline close at exit can sit behind an uninterruptible
343
+ wait held by another orphan writer.
344
+
345
+ The skip is keyed on **ownership**, not on a worker merely existing. A retired worker keeps
346
+ its old sink forever (``Worker.swap_sink`` returns early once ``_shutdown_done``) while every
347
+ orphan event goes to a newly configured one, so skipping on existence would leave that live
348
+ sink uninterruptible for the rest of the process. Where a worker does own the sink, its own
349
+ ``_stop`` is already there and is the event its drain loop waits on; overwriting it would
350
+ leave the drain thread serving a full backoff across ``Worker.shutdown``'s join, which is the
351
+ global pause SPEC-027 exists to remove.
352
+
353
+ A fresh event replaces one that is already set, because an ``Event`` never clears and
354
+ ``sinks/_retry.wait`` returns immediately on a set one — a sink handed the shutdown's event
355
+ would have every subsequent backoff collapsed to zero, which against a rate-limited
356
+ destination is a tight retry loop. SPEC-027's contract is "cut short by a shutdown", not
357
+ "never wait again".
358
+
359
+ Callers hold ``_worker_lock``.
360
+
361
+ Args:
362
+ sink: The sink to offer a signal to.
363
+
364
+ Returns:
365
+ None.
366
+
367
+ Raises:
368
+ None.
369
+ """
370
+ global _orphan_stop
371
+ worker = _live_worker()
372
+ if worker is not None and worker.sink is sink:
373
+ return
374
+ if _orphan_stop.is_set():
375
+ _orphan_stop = threading.Event()
376
+ _lifecycle.offer_stop_signal(sink, _orphan_stop)
271
377
 
272
378
 
273
379
  def _close_orphan_sink() -> None:
@@ -278,10 +384,17 @@ def _close_orphan_sink() -> None:
278
384
  on a synchronous one the flush and the resource were lost, and ``health()`` read all-clear
279
385
  because every field it carries describes a worker that does not exist.
280
386
 
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``.
387
+ A worker that owns *this* sink closes it instead, and this returns — that is what makes a
388
+ mixed process exactly one ``close()`` in either order. It also inherits that worker's reasons
389
+ for *not* closing: an expired :meth:`Worker.shutdown` leaves the sink open because the drain
390
+ thread may still be inside ``emit``, and there ``_worker.sink is owed`` still holds.
391
+
392
+ The guard is **ownership**, not a worker merely existing (SPEC-033 FR-002). The two stop
393
+ being the same question the moment the worker is retired: ``Worker.swap_sink`` returns early
394
+ once ``_shutdown_done``, so a retired worker keeps its old sink forever while every orphan
395
+ event goes to a newly configured one — measured, a sink configured after ``shutdown()`` was
396
+ then closed by nothing at all, losing a locally-buffering sink's whole batch while
397
+ ``health()`` read ``retired=True, submitted_after_shutdown=0, failed_batches=0``.
285
398
 
286
399
  That check is read **under** ``_worker_lock``, not ahead of it, because :func:`_get_worker`
287
400
  assigns ``_worker`` while holding that same lock. Unlocked, a ``shutdown()`` racing a first
@@ -303,13 +416,17 @@ def _close_orphan_sink() -> None:
303
416
  traceback carrying the message arch §6 keeps out of anything the library says about
304
417
  itself. ``Exception``, never ``BaseException`` (SPEC-025 FR-004).
305
418
  """
306
- global _orphan_sink_closed
419
+ global _orphan_sink, _orphan_closed_sink
307
420
  with _worker_lock:
308
- if _worker is not None or not _orphan_close_owed or _orphan_sink_closed:
421
+ owed = _orphan_sink
422
+ if owed is None:
309
423
  return
310
- _orphan_sink_closed = True
424
+ if _worker is not None and _worker.sink is owed:
425
+ return
426
+ _orphan_sink = None
427
+ _orphan_closed_sink = owed
311
428
  try:
312
- _ensure_sink().close()
429
+ owed.close()
313
430
  except Exception as exc:
314
431
  _diag.absorbed("closing the sink", exc, "it may still hold its resources")
315
432
 
@@ -327,6 +444,21 @@ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
327
444
  thread at exit to prove there is nothing to drain is pure cost, the same refusal
328
445
  :func:`_swap_sink` and :func:`_flush_worker` already make.
329
446
 
447
+ The worker branch runs :func:`_close_orphan_sink` before returning (SPEC-033 FR-002). A
448
+ retired worker owns nothing further, so a sink adopted after its shutdown is the orphan
449
+ path's to close; that function's ownership guard is what keeps this from double-closing the
450
+ sink the worker just closed itself.
451
+
452
+ ``_orphan_stop`` is set **before** delegating, so a sink parked in a backoff is released
453
+ while :meth:`Worker.shutdown` is still draining rather than after it has given up waiting.
454
+
455
+ The closer grace is granted **once**, by whichever path owns this call.
456
+ :meth:`Worker.shutdown` already grants it — on its successful path and on its idempotent one,
457
+ which is what covers a first shutdown that expired before reaching it — so joining again here
458
+ would charge a second full ``DEFAULT_CLOSER_GRACE`` against the same exit: measured 4.01 s
459
+ against a 2 s grace. The orphan branch grants it instead, where nothing else will, and gets it
460
+ on every path including the one where nothing was armed and the idempotent second call.
461
+
330
462
  Args:
331
463
  timeout: Seconds to wait for the drain, or ``None`` to wait indefinitely.
332
464
 
@@ -338,24 +470,69 @@ def _shutdown_worker(timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
338
470
  """
339
471
  global _orphan_retired
340
472
  _orphan_retired = True
473
+ _orphan_stop.set()
474
+ deadline = None if timeout is None else monotonic() + timeout
341
475
  if _worker is not None:
342
476
  _worker.shutdown(timeout)
477
+ _close_orphan_sink()
343
478
  return
344
479
  _close_orphan_sink()
480
+ _lifecycle.join_closers(None if deadline is None else max(0.0, deadline - monotonic()))
345
481
 
346
482
 
347
483
  def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) -> None:
348
- """Retargets the process worker at a new sink, backing a late ``configure(sink=...)``.
484
+ """Retargets delivery at a new sink, backing a late ``configure(sink=...)``.
349
485
 
350
486
  Like :func:`_flush_worker` this deliberately does not call :func:`_get_worker`: a process
351
487
  that has not logged has captured no sink, so there is nothing to swap and building a thread
352
- to prove it would be pure cost that is also the case where the old behaviour was already
353
- correct (SPEC-030 FR-003).
488
+ to prove it would be pure cost (SPEC-030 FR-003).
489
+
490
+ **Both delivery paths are handled here** (SPEC-033 FR-002). A worker delegates to
491
+ :meth:`Worker.swap_sink`, which owns the drains. With no worker there is nothing buffered to
492
+ drain — an orphan emit is synchronous and has returned before ``configure()`` was entered —
493
+ so the handoff is the close alone. Returning early there, as this did, left the previous sink
494
+ open forever with ``incomplete_swaps`` at zero, since every field of ``Health`` describes a
495
+ worker that does not exist.
496
+
497
+ The record is **re-pointed** at the new sink rather than cleared. Clearing would leave nothing
498
+ armed until the next orphan emit, so a process that swaps and then exits without logging again
499
+ would leak the *new* sink — measured, that case closes correctly today, so clearing would trade
500
+ one leak for another. Re-pointing is also what the worker path does: :meth:`Worker.shutdown`
501
+ closes ``self.sink`` whether or not anything was emitted to it since the swap.
502
+
503
+ No fence, either. The only writer that could still be inside the old sink's ``emit`` is an
504
+ orphan emitter on another application thread, and that is exactly the writer
505
+ :meth:`Worker._close_swapped_out` documents itself as not covering — which is why
506
+ ``sinks/base.py`` requires ``close()`` to tolerate a concurrent ``emit`` (SPEC-028 FR-001).
507
+ This inherits that contract rather than weakening it.
508
+
509
+ **Who performs the swap and who owns the old sink's close are two questions.** The first is
510
+ liveness — a retired worker performs nothing, since :meth:`Worker.swap_sink` returns early
511
+ once shut down, so routing the swap to it loses the handoff entirely. The second is
512
+ ownership, exactly as in :func:`_close_orphan_sink`: a worker that *holds* ``old`` has either
513
+ closed it already or deliberately left it open because its drain thread may still be inside
514
+ that sink's ``emit`` (SPEC-027 FR-004). Answering the second with liveness closes it a second
515
+ time on a clean shutdown, and closes it **under a live writer** on an expired one — both
516
+ measured, both introduced by the first version of this split, and the second is the outcome
517
+ ``sinks/base.py`` and SPEC-028 exist to prevent. So the record is re-pointed either way, and
518
+ the close is performed only when no worker holds it.
519
+
520
+ ``_worker`` is read **under** ``_worker_lock``. Unlocked it was harmless, because the
521
+ no-worker branch did nothing; once that branch closes a sink it is the race
522
+ :func:`_close_orphan_sink` was built against — a first ``@trace`` on another thread can be
523
+ inside ``Worker(...)`` with its sink already resolved while this thread reads ``None`` and
524
+ closes the sink that worker is about to deliver to. The closer's **join is outside** the
525
+ lock: it waits up to the swap's whole budget, and holding the process-wide lock across that
526
+ would park every concurrent emit behind it.
527
+
528
+ ``incomplete_swaps`` is deliberately not touched on the orphan path. It records a *drain*
529
+ that could not be confirmed (SPEC-030), and there is no drain here; an expired close join
530
+ reports nothing at all, by the decision that made the bounded close available.
354
531
 
355
532
  Args:
356
533
  new_sink: The sink already written to the config, to be made the live delivery target.
357
- timeout: Seconds bounding the whole swap — both drains and the close of the previous
358
- sink share it as one deadline.
534
+ timeout: Seconds bounding the whole swap — the drains, where there are any, and the close
535
+ of the previous sink share it as one deadline.
359
536
 
360
537
  Returns:
361
538
  None.
@@ -365,15 +542,31 @@ def _swap_sink(new_sink: Sink, timeout: float | None = DEFAULT_SWAP_TIMEOUT) ->
365
542
  rejected ceiling, and a sink swap that fails must not become the reason an application
366
543
  cannot start.
367
544
  """
368
- worker = _worker
369
- if worker is None:
545
+ global _orphan_sink, _orphan_closed_sink
546
+ closer = None
547
+ with _worker_lock:
548
+ worker = _live_worker()
549
+ if worker is not None:
550
+ _orphan_sink = None
551
+ else:
552
+ old = _orphan_sink
553
+ if old is None or old is new_sink:
554
+ return
555
+ _orphan_sink = new_sink
556
+ _offer_orphan_signal(new_sink)
557
+ if _worker is None or _worker.sink is not old:
558
+ _orphan_closed_sink = old
559
+ closer = _lifecycle.close_detached(old)
560
+ if worker is not None:
561
+ try:
562
+ worker.swap_sink(new_sink, timeout)
563
+ except Exception as exc:
564
+ _diag.absorbed(
565
+ "swapping the log sink", exc, "events may still be delivered to the previous sink"
566
+ )
370
567
  return
371
- try:
372
- worker.swap_sink(new_sink, timeout)
373
- except Exception as exc:
374
- _diag.absorbed(
375
- "swapping the log sink", exc, "events may still be delivered to the previous sink"
376
- )
568
+ if closer is not None:
569
+ closer.join(timeout)
377
570
 
378
571
 
379
572
  def _flush_worker(timeout: float | None = 5.0) -> bool:
@@ -441,7 +634,13 @@ def _worker_health() -> Health:
441
634
  """
442
635
  worker = _worker
443
636
  if worker is None:
444
- return Health(queued=0, dropped=0, failed_batches=0, retired=_orphan_retired)
637
+ return Health(
638
+ queued=0,
639
+ dropped=0,
640
+ failed_batches=0,
641
+ retired=_orphan_retired,
642
+ closing_sinks=_lifecycle.closing_count(),
643
+ )
445
644
  health = worker.health()
446
645
  if _orphan_retired and not health.retired:
447
646
  return health._replace(retired=True)
@@ -7,7 +7,7 @@ import threading
7
7
  import time
8
8
  from typing import TYPE_CHECKING, NamedTuple, cast
9
9
 
10
- from log_foundry import _diag
10
+ from log_foundry import _diag, _lifecycle
11
11
 
12
12
  if TYPE_CHECKING:
13
13
  from log_foundry.sinks.base import Sink, SinkLosses
@@ -26,15 +26,6 @@ and an unbounded join there is a hung process.
26
26
 
27
27
  _DROP_WARN_EVERY = 1000
28
28
 
29
- DEFAULT_CLOSER_GRACE = 2.0
30
- """Seconds :meth:`Worker.shutdown` gives an outstanding swapped-out close to finish.
31
-
32
- Deliberately much smaller than the shutdown budget it is carved from. This is a last chance for
33
- a close that is *nearly* done, not a second full attempt: it already had the swap's whole budget
34
- (``DEFAULT_SWAP_TIMEOUT``) before ``shutdown`` was ever called, so one still running here is far
35
- more likely stuck than slow, and every second spent on it is a second the process does not exit.
36
- """
37
-
38
29
  DEFAULT_SWAP_TIMEOUT = 5.0
39
30
  """Seconds a late ``configure(sink=...)`` will spend draining the previous sink (FR-003).
40
31
 
@@ -114,6 +105,12 @@ class Health(NamedTuple):
114
105
  call may have been carried to the new sink instead of the old one, and the old sink was
115
106
  left **open** rather than closed, because the drain thread may still be inside its
116
107
  ``emit`` — the reasoning SPEC-027 FR-004 applies to an expired ``shutdown()``.
108
+ It describes the **worker's drain** and nothing else. A swap on the orphan path has no
109
+ queue and no drain, so there is nothing to confirm and this stays zero there (SPEC-033
110
+ FR-006); an expired *close* join reports nothing on either path, by the decision that
111
+ made the bounded close available at all. A non-zero value therefore always means events
112
+ may have been misrouted, never merely that a close was slow — ``closing_sinks`` is the
113
+ field for that.
117
114
  closing_sinks: Swapped-out sinks whose ``close()`` is running *at this instant* — a live
118
115
  gauge, not a counter, and the only field here that can fall as well as rise. A close is
119
116
  bounded only in how long ``configure()`` waits for it, so this is how a destination
@@ -226,7 +223,6 @@ class Worker:
226
223
  self.stopped_reason: str | None = None
227
224
  self.submitted_after_shutdown = 0
228
225
  self.incomplete_swaps = 0
229
- self._closers: list[threading.Thread] = []
230
226
  self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
231
227
  self._stop = threading.Event()
232
228
  self._drain_finished = threading.Event()
@@ -258,13 +254,7 @@ class Worker:
258
254
  ``__setattr__`` objects, loses interruptibility rather than preventing the worker
259
255
  from starting.
260
256
  """
261
- try:
262
- if hasattr(self.sink, "stop_signal"):
263
- self.sink.stop_signal = self._stop
264
- except Exception as exc:
265
- _diag.absorbed(
266
- "handing the sink its stop signal", exc, "its backoff stays uninterruptible"
267
- )
257
+ _lifecycle.offer_stop_signal(self.sink, self._stop)
268
258
 
269
259
  def submit(self, events: list[dict[str, object]]) -> None:
270
260
  """Hands a finished span's events to the worker, without blocking.
@@ -337,6 +327,30 @@ class Worker:
337
327
  "flush() in a process that logs again. Count is cumulative",
338
328
  )
339
329
 
330
+ @property
331
+ def retired(self) -> bool:
332
+ """Whether :meth:`shutdown` has begun, so this worker will deliver nothing further.
333
+
334
+ A retired worker still *holds* its sink — ``self.sink`` never changes again, because
335
+ :meth:`swap_sink` returns early once shut down — so "a worker exists" and "a worker is
336
+ still delivering" stop being the same question, and callers deciding who owns a sink's
337
+ close need the second one (SPEC-033 FR-002). Read without the lock, as ``submit``'s check
338
+ is: the flag is written once and never cleared, so a racing reader sees one of two
339
+ answers and both are momentarily true.
340
+
341
+ Args:
342
+ None.
343
+
344
+ Returns:
345
+ Whether shutdown has begun. ``True`` for an expired shutdown too, which is why
346
+ ``_close_orphan_sink`` deliberately does *not* use this — there the drain thread may
347
+ still be inside the sink's ``emit``.
348
+
349
+ Raises:
350
+ None.
351
+ """
352
+ return self._shutdown_done
353
+
340
354
  def health(self) -> Health:
341
355
  """Snapshots the delivery counters (SPEC-017 FR-005, SPEC-019 FR-003).
342
356
 
@@ -365,8 +379,6 @@ class Worker:
365
379
  retired = self._shutdown_done
366
380
  submitted_after_shutdown = self.submitted_after_shutdown
367
381
  incomplete_swaps = self.incomplete_swaps
368
- self._closers = [closer for closer in self._closers if closer.is_alive()]
369
- closing_sinks = len(self._closers)
370
382
  return Health(
371
383
  queued=self._queue.qsize(),
372
384
  dropped=dropped,
@@ -376,7 +388,7 @@ class Worker:
376
388
  retired=retired,
377
389
  submitted_after_shutdown=submitted_after_shutdown,
378
390
  incomplete_swaps=incomplete_swaps,
379
- closing_sinks=closing_sinks,
391
+ closing_sinks=_lifecycle.closing_count(),
380
392
  )
381
393
 
382
394
  def _sink_losses(self) -> SinkLosses | None:
@@ -606,47 +618,9 @@ class Worker:
606
618
  wait this method exists to remove, in the one situation where the process is
607
619
  already under resource pressure.
608
620
  """
609
- closer = threading.Thread(
610
- target=self._close_detached,
611
- args=(sink,),
612
- name="log-foundry-sink-close",
613
- daemon=True,
614
- )
615
- try:
616
- closer.start()
617
- except Exception as exc:
618
- _diag.absorbed(
619
- "starting the thread that closes a swapped-out sink",
620
- exc,
621
- "it is left open and may still hold its resources",
622
- )
623
- return
624
- with self._lock:
625
- self._closers = [old for old in self._closers if old.is_alive()]
626
- self._closers.append(closer)
627
- closer.join(timeout)
628
-
629
- def _close_detached(self, sink: Sink) -> None:
630
- """Closes a swapped-out sink on its own thread, absorbing a failure.
631
-
632
- The guard is what makes the thread safe to leave unattended: an exception escaping here
633
- would reach CPython's thread bootstrap, which prints a full traceback carrying the
634
- exception's message — the user data arch §6 keeps out of anything the library says about
635
- itself, and the reason :meth:`shutdown` guards its own close.
636
-
637
- Args:
638
- sink: The sink to close.
639
-
640
- Returns:
641
- None.
642
-
643
- Raises:
644
- None.
645
- """
646
- try:
647
- sink.close()
648
- except Exception as exc:
649
- _diag.absorbed("closing a swapped-out sink", exc, "it may still hold its resources")
621
+ closer = _lifecycle.close_detached(sink)
622
+ if closer is not None:
623
+ closer.join(timeout)
650
624
 
651
625
  def shutdown(self, timeout: float | None = DEFAULT_SHUTDOWN_TIMEOUT) -> None:
652
626
  """Stops the thread, drains and emits everything queued, then closes the sink.
@@ -784,13 +758,7 @@ class Worker:
784
758
  None. A join on a thread that has already finished is a no-op, and one that has not
785
759
  is abandoned at the deadline — which is the daemon's contract, not a failure.
786
760
  """
787
- with self._lock:
788
- closers = [closer for closer in self._closers if closer.is_alive()]
789
- self._closers = closers
790
- grace = DEFAULT_CLOSER_GRACE if timeout is None else min(timeout, DEFAULT_CLOSER_GRACE)
791
- deadline = time.monotonic() + grace
792
- for closer in closers:
793
- closer.join(max(0.0, deadline - time.monotonic()))
761
+ _lifecycle.join_closers(timeout)
794
762
 
795
763
  def _close_if_owed(self) -> None:
796
764
  """Closes the sink exactly once, and only once the drain thread has ended.