log-foundry 0.10.2.dev61__tar.gz → 0.10.2.dev62__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 (60) hide show
  1. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/PKG-INFO +1 -1
  2. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/pyproject.toml +1 -1
  3. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/_fork.py +40 -0
  4. log_foundry-0.10.2.dev62/src/log_foundry/_lifecycle.py +699 -0
  5. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/config.py +8 -1
  6. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/filtering.py +1 -1
  7. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/logstash.py +1 -1
  8. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/multi.py +1 -1
  9. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sentry.py +1 -1
  10. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/transform.py +1 -1
  11. log_foundry-0.10.2.dev61/src/log_foundry/_lifecycle.py +0 -212
  12. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/LICENSE +0 -0
  13. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/README.md +0 -0
  14. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/__init__.py +0 -0
  15. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/_diag.py +0 -0
  16. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/api.py +0 -0
  17. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/console.py +0 -0
  18. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/context.py +0 -0
  19. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/decorator.py +0 -0
  20. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/ids.py +0 -0
  21. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/model.py +0 -0
  22. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/py.typed +0 -0
  23. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/results.py +0 -0
  24. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sanitize.py +0 -0
  25. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/__init__.py +0 -0
  26. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_batch.py +0 -0
  27. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_chunk.py +0 -0
  28. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_retry.py +0 -0
  29. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_socket.py +0 -0
  30. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_time.py +0 -0
  31. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/base.py +0 -0
  32. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/callback.py +0 -0
  33. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/clickhouse.py +0 -0
  34. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/datadog.py +0 -0
  35. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/elasticsearch.py +0 -0
  36. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/eventhubs.py +0 -0
  37. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/file.py +0 -0
  38. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/firehose.py +0 -0
  39. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/honeycomb.py +0 -0
  40. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/http.py +0 -0
  41. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/kafka.py +0 -0
  42. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/kinesis.py +0 -0
  43. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/logging_sink.py +0 -0
  44. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/loki.py +0 -0
  45. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/memory.py +0 -0
  46. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/mongodb.py +0 -0
  47. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/nats.py +0 -0
  48. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/newrelic.py +0 -0
  49. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/null.py +0 -0
  50. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/postgres.py +0 -0
  51. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/pubsub.py +0 -0
  52. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/rabbitmq.py +0 -0
  53. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/redis.py +0 -0
  54. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sns.py +0 -0
  55. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/splunk.py +0 -0
  56. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sqlite.py +0 -0
  57. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sqs.py +0 -0
  58. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/stdout.py +0 -0
  59. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/syslog.py +0 -0
  60. {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: log-foundry
3
- Version: 0.10.2.dev61
3
+ Version: 0.10.2.dev62
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.dev61"
23
+ version = "0.10.2.dev62"
24
24
 
25
25
  [project.optional-dependencies]
26
26
  aws = ["boto3>=1.43.61"] # SQSSink, SNSSink, KinesisSink, FirehoseSink
@@ -366,6 +366,43 @@ def _offers_discard(holder: object) -> bool:
366
366
  return False
367
367
 
368
368
 
369
+ _SKIP_ATTRIBUTE = "_FORK_SKIP"
370
+ """A module's opt-out: attribute names the walk must not read or descend into.
371
+
372
+ **Bookkeeping that pins objects is not live state to repair**, and the difference is invisible
373
+ to a walk that only sees a container. ``_lifecycle._owned`` is the case: SPEC-042 FR-001 requires
374
+ it to hold a **strong** reference to every sink this process ever acquired, so without an opt-out
375
+ the walk reaches sinks the process abandoned several ``configure()`` calls ago — replacing their
376
+ locks, which is merely wasteful, and calling their fork hooks, which is not. Measured: a forked
377
+ child announced a buffer-discard failure for a sink that had been superseded, and a ``FileSink``
378
+ in that state would have its file reopened on every fork for the life of the process.
379
+
380
+ Declared by the module that owns the state rather than listed here, which keeps this module's
381
+ rule that it imports nothing but ``_diag`` (SPEC-039 FR-006). Nothing is lost by skipping: a sink
382
+ that is still *live* is reached through the config and the worker, which are not opted out.
383
+ """
384
+
385
+
386
+ def _skipped_names(holder: object) -> frozenset[str]:
387
+ """Returns the attribute names a holder has opted out of the walk.
388
+
389
+ Args:
390
+ holder: The module, class or instance the walk is about to read.
391
+
392
+ Returns:
393
+ The opted-out names, empty for anything that declares none.
394
+
395
+ Raises:
396
+ None. A malformed or unreadable declaration opts nothing out, so the walk does more work
397
+ rather than less — the safe direction for a repair whose absence is a hang.
398
+ """
399
+ try:
400
+ declared = getattr(holder, _SKIP_ATTRIBUTE, None)
401
+ return frozenset(declared) if declared else frozenset()
402
+ except Exception:
403
+ return frozenset()
404
+
405
+
369
406
  def _reinit_primitives() -> list[Any]:
370
407
  """Replaces every lock and event this package owns, wherever the walk reaches one.
371
408
 
@@ -417,7 +454,10 @@ def _reinit_primitives() -> list[Any]:
417
454
  continue
418
455
  if _offers_discard(holder):
419
456
  buffered.append(holder)
457
+ skip = _skipped_names(holder)
420
458
  for name, value in _namespace_items(holder):
459
+ if name in skip:
460
+ continue
421
461
  fresh = _fresh_primitive(value, memo, keepalive)
422
462
  if fresh is not None:
423
463
  _assign(holder, name, fresh)
@@ -0,0 +1,699 @@
1
+ """Sink-lifecycle facilities shared by both delivery paths (SPEC-033 FR-005)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import threading
7
+ import time
8
+ import types
9
+ from itertools import islice
10
+
11
+ from log_foundry import _diag, _fork
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
+ _FORK_SKIP = ("_owned",)
27
+ """Keeps the ownership record out of ``_fork``'s repair walk (``_fork._SKIP_ATTRIBUTE``).
28
+
29
+ The record strongly references every sink this process ever acquired, so the walk would
30
+ otherwise reach ones the process abandoned several ``configure()`` calls ago and call their fork
31
+ hooks — measured, a child announced a buffer discard for a superseded sink, and a ``FileSink``
32
+ there would be reopened on every fork forever. A sink that is still live is reached through the
33
+ config and the worker, so nothing the repair needs is lost.
34
+ """
35
+
36
+ _FOREIGN = -1
37
+ """The pid a record carries when the sink belongs to some earlier process.
38
+
39
+ Never a real pid, so it can never match :func:`os.getpid`. Laid down by
40
+ :func:`_mark_inherited` in a forked child over everything the child inherited, which is what
41
+ gives "this process did not acquire it" a **terminal** state.
42
+
43
+ Without it the record protects nothing where it is empty: ``stamp`` is write-once, and write-once
44
+ defends only a record that already exists, so a child could ``configure()`` its way into *owning*
45
+ a sink the parent never recorded and then close it entirely legitimately. Measured before this
46
+ existed — a child claimed a connection sink held behind a third-party wrapper and closed it,
47
+ destroying the parent's transport. Unrecorded has to be unclaimable, not merely unreleasable.
48
+ """
49
+
50
+ _MARKING_CEILING = 100_000
51
+ """Objects the child's marking walk may visit before it gives up and refuses everything.
52
+
53
+ **A cap is right here where SPEC-039 rejected one, and the difference is the fallback.** That
54
+ spec declined to bound its repair walk because an unfound lock is a child that hangs with no
55
+ safe degradation — a cap would trade a certain hazard for an uncertain one. This walk has
56
+ :data:`_marking_failed`, built for exactly "it did not finish, so trust nothing unrecorded", so
57
+ tripping the cap degrades to a leaked handle: the direction FR-001 requires.
58
+
59
+ The exposure is also larger than that walk's. ``_reinit_primitives`` enters containers only from
60
+ owned holders; this one enters every container reachable through arbitrary third-party objects,
61
+ and a ``list`` subclass with a non-terminating ``__iter__`` took a child to **5.7 GB RSS in nine
62
+ minutes**, unkillable by its parent because the parent was being starved. Exhausting memory in a
63
+ child that has not returned from ``fork`` is worse than the leak the cap causes.
64
+
65
+ Set far above any real graph — a sink's is on the order of hundreds — and above the ~24,000 the
66
+ module escape used to reach before :func:`_mark_inherited` stopped descending into modules.
67
+ """
68
+
69
+ _marking_failed = False
70
+ """Whether the child's marking walk could not finish, so nothing unrecorded may be trusted.
71
+
72
+ The walk is what makes an inherited sink recorded; if it did not complete, an unrecorded sink in
73
+ this child may be one it missed rather than one this process built. Refusing every unrecorded
74
+ sink then costs a leaked handle, which is the direction FR-001 requires a gap to fail in.
75
+ """
76
+
77
+ _owned: dict[int, tuple[int, object]] = {}
78
+ """Which sinks this process acquired, keyed by ``id`` and holding the pid that acquired them.
79
+
80
+ **No record means refused** (FR-001), and that default is the whole mechanism: a sink the library
81
+ was never handed was never its to release, so every gap fails toward a leaked handle rather than
82
+ toward closing a transport another process is still using.
83
+
84
+ The value holds a **strong reference beside the pid**, which is load-bearing twice. An ``id`` is
85
+ reusable the moment its object dies, so a bare pid could be handed to an unrelated later object
86
+ that happened to land on the address; and a garbage-collected sink closes itself, which is the
87
+ same destructive close by another route. ``_fork._fresh_primitive`` already pairs an id with a
88
+ keepalive for the first of those reasons.
89
+
90
+ It therefore grows by one entry per sink ever handed to the library and never shrinks. That is
91
+ accepted rather than bounded: ``configure()`` is a startup call, so the count is startup-scale,
92
+ and evicting an entry is exactly the "no record" state that makes a sink unreleasable.
93
+ """
94
+
95
+ _owned_lock = threading.Lock()
96
+ """Guards :data:`_owned`, and is the **last** lock in the process's order (FR-001 AC-12).
97
+
98
+ ``_worker_lock`` → ``_config_lock`` → this, never the reverse in any pair. The three-term form
99
+ is the real one: ``decorator._get_worker`` calls ``config._ensure_sink()`` while holding
100
+ ``_worker_lock``, and ``_ensure_sink``'s construction branch takes ``_config_lock`` before it can
101
+ stamp. The criterion states the two-term version, which is true but skips the middle term.
102
+
103
+ The orphan logging path is the opposite constraint and takes **no** lock at all: it reaches
104
+ ``_ensure_sink``'s fast-path return once per event, which must never stamp (AC-10).
105
+ """
106
+
107
+ _PLAIN_TYPES = frozenset(
108
+ {dict, list, tuple, set, frozenset, str, bytes, int, float, bool, type(None)}
109
+ )
110
+ """Builtin types that provably cannot be a sink, skipped before any structural test.
111
+
112
+ A fact rather than a heuristic: ``Sink`` requires ``emit`` and ``close``, and no builtin has
113
+ either. Tested by **exact type, never ``isinstance``**, so a ``class MySink(dict)`` is still
114
+ asked — measured, the exact form answers ``True`` for a dict-subclass sink and ``False`` for a
115
+ plain dict.
116
+
117
+ It is the single largest term in the walk's cost, because a buffering sink's contents are almost
118
+ entirely these: skipping them took a ``MemorySink`` holding 100k events from 1,109 ms to 279 ms
119
+ before the descent was bounded at all (FR-001 AC-11).
120
+ """
121
+
122
+
123
+ def _may_be_a_sink(value: object) -> bool:
124
+ """Whether a value is worth asking the structural question about at all.
125
+
126
+ Args:
127
+ value: Any object reached by the walk.
128
+
129
+ Returns:
130
+ Whether its exact type is something other than a plain builtin.
131
+
132
+ Raises:
133
+ None.
134
+ """
135
+ return type(value) not in _PLAIN_TYPES
136
+
137
+
138
+ def _bounded_children(container: object) -> list[object]:
139
+ """Reads at most :data:`_MARKING_CEILING` members of one container, without materialising it.
140
+
141
+ **The per-object ceiling in the walk cannot save a walk stuck inside one call**, which is
142
+ where ``_fork._container_children`` puts it: that helper does ``list(container)``, and a
143
+ ``list`` subclass with a non-terminating ``__iter__`` never returns from it. Measured — a
144
+ child reached 5.7 GB RSS in nine minutes and its parent's own timeout could not kill it,
145
+ because the parent was being starved. So the bound has to be on the *read*, not only on the
146
+ loop around it.
147
+
148
+ Args:
149
+ container: Any value ``_fork._is_container`` accepted.
150
+
151
+ Returns:
152
+ Its members, keys included for a mapping, truncated at the ceiling.
153
+
154
+ Raises:
155
+ None. A container that raises while being read contributes nothing, as it does in
156
+ ``_fork``; what it holds is then unmarked, therefore unrecorded, therefore refused.
157
+ """
158
+ try:
159
+ if isinstance(container, dict):
160
+ return [
161
+ *islice(container.keys(), _MARKING_CEILING),
162
+ *islice(container.values(), _MARKING_CEILING),
163
+ ]
164
+ return list(islice(container, _MARKING_CEILING)) # type: ignore[call-overload]
165
+ except Exception as exc:
166
+ _diag.absorbed(
167
+ "reading a container while marking a forked child's sinks",
168
+ exc,
169
+ "what it holds is not marked, so this child will refuse to close it",
170
+ )
171
+ return []
172
+
173
+
174
+ def _reachable_sinks(root: object) -> list[object]:
175
+ """Returns every sink reachable from one object handed to the library (FR-001).
176
+
177
+ A wrapper is handed over *with* its children, so the same act acquires them and the record
178
+ has to reach the whole graph — a first draft stamped only what ``configure()`` was given, and
179
+ a structural sink inside a ``MultiSink`` was then neither stamped nor reachable by
180
+ ``_fork``'s mark, which is the object a forked child closed twice.
181
+
182
+ **Reaching the graph is a descent question, not an ownership one**, and that distinction is
183
+ what keeps this inside SPEC-039's boundary. The walk enters library objects and plain
184
+ containers as that module's predicates already define, and *records* any sink-shaped member
185
+ it meets even where it will not descend into it. Recording reads nothing from the object: an
186
+ ``id``, a reference, and the two attribute lookups a runtime-checkable Protocol performs.
187
+ "Do not reach into third-party state" (SPEC-039 FR-003 AC-2) forbids mutating and traversing
188
+ a foreign object, not noticing one.
189
+
190
+ **A container is scanned one level and never recursed into**, which is a bound chosen with a
191
+ measurement in hand (FR-001 AC-11). A sink is never inside *caller data*: it is an owned
192
+ object's attribute, or a member of a container an owned object holds directly, which is what
193
+ ``MultiSink._sinks`` is. Unbounded descent enters every event dict a buffering sink holds and
194
+ measured 279 ms on a ``MemorySink`` with 100k events against 2 ms for this; both return an
195
+ identical set for every shape the library ships or ``README.md`` documents. What the bound
196
+ gives up is a sink two container hops below an owned holder, which is then unrecorded,
197
+ therefore refused, therefore **leaked rather than destructively closed** — the one direction
198
+ FR-001 permits a gap to fail in.
199
+
200
+ **Sink-shaped is tested before container-shaped**, and the order is load-bearing. A sink
201
+ whose class subclasses a builtin container — ``class MySink(dict)``, or anything built on a
202
+ ``NamedTuple`` — satisfies both tests, and with the container branch first it was read as a
203
+ bag of members and never recorded. Held as a bare attribute that is exactly
204
+ ``FilteringSink._inner``, so ``FilteringSink(MySink()).close()`` silently closed nothing:
205
+ measured, a regression against the unguarded release this replaced, needing no fork at all.
206
+ ``MultiSink`` escaped only by accident of position, its children arriving through the
207
+ container branch. A plain ``tuple`` or ``list`` is not sink-shaped, so the wrapper case still
208
+ takes the container branch as it must.
209
+
210
+ **The order is a trade, not a free win.** A value satisfying *both* tests is now pushed as a
211
+ holder, and this walk's holder loop has no container branch, so an owned non-sink container
212
+ subclass and a container-subclass sink's own members both lose reach. Neither is a
213
+ destructive close — ``_mark_inherited`` descends unboundedly and compensates in the child —
214
+ and no shipped sink holds either shape; the residual is an own-process leak, which is the
215
+ direction FR-001 permits, taken in exchange for closing a live silent one.
216
+
217
+ Args:
218
+ root: The object ``configure()`` or ``_ensure_sink()`` was handed.
219
+
220
+ Returns:
221
+ The sinks reached, each once, in the order the walk met them.
222
+
223
+ Raises:
224
+ None. The reads are the ones ``_fork`` already guards, and a graph that cannot be walked
225
+ fully leaves the unreached sinks unrecorded — refused, which is the safe default.
226
+ """
227
+ seen: set[int] = set()
228
+ found: list[object] = []
229
+ stack: list[object] = [root]
230
+ while stack:
231
+ holder = stack.pop()
232
+ if id(holder) in seen or not _may_be_a_sink(holder):
233
+ continue
234
+ seen.add(id(holder))
235
+ if isinstance(holder, Sink):
236
+ found.append(holder)
237
+ if not _fork._is_owned(holder):
238
+ continue
239
+ for _name, value in _fork._namespace_items(holder):
240
+ if _is_candidate(value):
241
+ stack.append(value)
242
+ elif _fork._is_container(value):
243
+ stack.extend(
244
+ member for member in _bounded_children(value) if _is_candidate(member)
245
+ )
246
+ return found
247
+
248
+
249
+ def _is_candidate(value: object) -> bool:
250
+ """Whether the walk pushes this value onto its stack.
251
+
252
+ Args:
253
+ value: A member or attribute the walk has just read.
254
+
255
+ Returns:
256
+ Whether it is either sink-shaped or an object this package defines.
257
+
258
+ Raises:
259
+ None.
260
+ """
261
+ return _may_be_a_sink(value) and (isinstance(value, Sink) or _fork._is_owned(value))
262
+
263
+
264
+ def _inheritance_roots() -> list[object]:
265
+ """Returns everything a forked child may have inherited a sink through.
266
+
267
+ The live delivery targets and every sink already recorded, which together are the only
268
+ handles the library itself holds at fork time.
269
+
270
+ **A sink reachable from none of them is not thereby safe**, and saying so was worse than
271
+ saying nothing. The residual is real and measured: a parent that builds a connection sink in
272
+ application state and never hands it to the library — `sink = SocketSink(...)` at import in
273
+ a gunicorn master — leaves nothing for this walk to find, and a child whose ``post_fork``
274
+ calls ``configure(sink=that_object)`` is the *first* process to hand it over, so it acquires
275
+ it legitimately and closes the parent's transport at shutdown. That cannot be decided here:
276
+ FR-001's rule is that the library may release what it was handed, FR-001 AC-3 requires a
277
+ child's ``configure()``d sink to be releasable, and nothing distinguishes the two without
278
+ marking the whole heap. It is recorded as a constraint in ``architecture.md`` §13 rather
279
+ than asserted away, and ``README.md``'s "build a connection-holding sink in the worker
280
+ process" is exactly the deployment advice that avoids it.
281
+
282
+ ``_owned.values()`` is the load-bearing entry, not the four live handles. It is the only one
283
+ that reaches a sink held inside a **superseded** wrapper — one ``configure()`` replaced, so
284
+ it is no live target, while the transport beneath it is still the parent's. Dropping it is a
285
+ destructive close; dropping any of the other four changes nothing, since each is itself
286
+ stamped and therefore already in the record.
287
+
288
+ Args:
289
+ None.
290
+
291
+ Returns:
292
+ The objects :func:`_mark_inherited` starts its walk from.
293
+
294
+ Raises:
295
+ None. A root that cannot be read is skipped; a partial roster marks less and therefore
296
+ refuses more, which is the safe direction.
297
+ """
298
+ from log_foundry import config, decorator
299
+
300
+ worker = decorator._worker
301
+ candidates = (
302
+ config._live_config().sink,
303
+ None if worker is None else worker.sink,
304
+ decorator._orphan_sink,
305
+ decorator._orphan_closed_sink,
306
+ )
307
+ roots: list[object] = [found for found in candidates if found is not None]
308
+ with _owned_lock:
309
+ roots.extend([reference for _pid, reference in _owned.values()])
310
+ return roots
311
+
312
+
313
+ def _mark_inherited() -> None:
314
+ """Marks every sink this child inherited as foreign, before anything can claim it (FR-001).
315
+
316
+ Registered with ``_fork`` and run in the child, which is why it may take the library's locks:
317
+ they were re-initialised moments earlier. It must run **before** any handler that could reach
318
+ a release path, which registration order provides and a test pins.
319
+
320
+ **This walk descends further than :func:`_reachable_sinks` deliberately, including into
321
+ third-party objects.** That is the whole point: a connection sink held inside a wrapper the
322
+ library does not own is invisible to the bounded stamp walk, so the parent never recorded it
323
+ — and a child that re-wraps that same object in a ``MultiSink`` of its own then reaches it,
324
+ claims it, and closes the parent's transport. Reproduced. The read is
325
+ ``_fork._namespace_items``, the same one the fork repair uses: instance ``__dict__`` and slot
326
+ descriptors only, so no property is triggered, nothing is mutated, and nothing is called.
327
+ SPEC-039 FR-003 AC-2 forbids mutating and traversing foreign state to *repair* it; noticing
328
+ what a wrapper holds so as to leave it alone is the opposite obligation, and FR-001 already
329
+ draws that line for recording.
330
+
331
+ The cost is one-off per fork and of the same order as the repair walk beside it: measured in
332
+ the child at 0.0 ms idle, 0.3 ms for a 50-deep ``MultiSink`` and **117 ms** for a
333
+ ``MemorySink`` holding 100k events, against SPEC-039's 202 ms for the repair walk. Fork cost
334
+ roughly doubles rather than changing character. The pre-filter gates the structural test and
335
+ the descent, not the push, so this is not :func:`_reachable_sinks`' number and the two must
336
+ not be quoted for each other.
337
+
338
+ Args:
339
+ None.
340
+
341
+ Returns:
342
+ None.
343
+
344
+ Raises:
345
+ None. An escaping exception sets :data:`_marking_failed`, after which every unrecorded
346
+ sink in this child is refused rather than trusted — a leaked handle instead of a
347
+ destructive close. **That covers less than it appears to**: every read the walk makes is
348
+ already absorbed one level down in ``_fork``, which returns empty and announces, so the
349
+ common failure is a *partial* walk that raises nothing and leaves the flag clear. Sinks
350
+ it did not reach are then unrecorded rather than marked. The outer guard is for a fault
351
+ in this function's own frame — resolving the roots, or an object whose ``__class__``
352
+ property raises, which makes ``isinstance`` raise here. Not a hostile *metaclass*: a
353
+ value's ``__instancecheck__`` is never consulted, since ``Sink``'s own ``_ProtocolMeta``
354
+ runs. Tripping :data:`_MARKING_CEILING` sets the flag too, by the same route and for the
355
+ same reason. The partial-walk residual is recorded in §13.
356
+ """
357
+ global _marking_failed
358
+ try:
359
+ seen: set[int] = set()
360
+ found: list[object] = []
361
+ stack: list[object] = _inheritance_roots()
362
+ while stack:
363
+ if len(seen) >= _MARKING_CEILING:
364
+ _marking_failed = True
365
+ _diag.lost(
366
+ "object",
367
+ len(stack),
368
+ f"the walk marking a forked child's inherited sinks passed "
369
+ f"{_MARKING_CEILING} objects and stopped; this child will refuse to close "
370
+ f"any sink it has no record of",
371
+ )
372
+ break
373
+ holder = stack.pop()
374
+ if id(holder) in seen or isinstance(holder, types.ModuleType):
375
+ continue
376
+ seen.add(id(holder))
377
+ if _may_be_a_sink(holder) and isinstance(holder, Sink):
378
+ found.append(holder)
379
+ if _fork._is_container(holder):
380
+ stack.extend(_bounded_children(holder))
381
+ continue
382
+ if not _may_be_a_sink(holder):
383
+ continue
384
+ for _name, value in _fork._namespace_items(holder):
385
+ if _fork._is_container(value) or _may_be_a_sink(value):
386
+ stack.append(value)
387
+ except Exception as exc:
388
+ _marking_failed = True
389
+ _diag.absorbed(
390
+ "marking the sinks a forked child inherited",
391
+ exc,
392
+ "this child will refuse to close any sink it has no record of",
393
+ )
394
+ return
395
+ with _owned_lock:
396
+ for inherited in found:
397
+ _owned.setdefault(id(inherited), (_FOREIGN, inherited))
398
+
399
+
400
+ def stamp(sink: object) -> None:
401
+ """Records that this process acquired a sink, and everything reachable from it (FR-001).
402
+
403
+ Called at the one moment ownership is knowable — when the library is *handed* a sink, by
404
+ ``configure(sink=…)`` or by ``_ensure_sink()`` building the lazy default — and never on
405
+ ``_ensure_sink``'s fast-path return, which runs once per orphan event (AC-10).
406
+
407
+ **Write-once per object.** A stamp naming another process is never overwritten, so a forked
408
+ child cannot claim an inherited sink by configuring its way back to it, and the answer
409
+ survives a second fork. Overwriting on every ``configure()`` satisfies every other criterion
410
+ in FR-001 and fails AC-4.
411
+
412
+ The walk runs **outside** the lock and only the record write takes it, so an arbitrary
413
+ object graph is never traversed while holding a process-wide lock.
414
+
415
+ Args:
416
+ sink: The sink being installed, whose reachable graph is acquired with it.
417
+
418
+ Returns:
419
+ None.
420
+
421
+ Raises:
422
+ None. This runs inside ``configure()``, which must not fail an application's startup over
423
+ a bookkeeping step; an unrecorded sink is a refused one, which leaks rather than closes.
424
+ """
425
+ try:
426
+ reachable = _reachable_sinks(sink)
427
+ except Exception as exc:
428
+ _diag.absorbed("recording which sinks this process owns", exc, "they will not be closed")
429
+ return
430
+ pid = os.getpid()
431
+ with _owned_lock:
432
+ for found in reachable:
433
+ _owned.setdefault(id(found), (pid, found))
434
+
435
+
436
+ def releasable(sink: object, *, owner: object = None) -> bool:
437
+ """Whether this process may close a sink (FR-001).
438
+
439
+ A recorded sink answers for itself: releasable exactly when the record names this process.
440
+ That is the whole mechanism for the defect — after a fork every stamp names the parent, so a
441
+ child refuses the object it inherited.
442
+
443
+ **An *unrecorded* sink inherits the answer from whatever is releasing it**, and that is a
444
+ correction to FR-001's flat "no record means refused". Every lifecycle path stamps: a sink
445
+ reaches the worker or the orphan record only through ``config._ensure_sink()``, which the
446
+ two acquisition points cover. So "no record" never occurs on a path the fork defect travels
447
+ — it occurs when a **user** calls ``close()`` on a wrapper the library was never handed, and
448
+ refusing there turns a documented public API into a silent no-op, which is the failure mode
449
+ this whole arc exists to remove. ``FilteringSink(inner).close()`` must still close ``inner``.
450
+
451
+ The wrapper is what makes the two distinguishable, so it is asked:
452
+
453
+ - Neither recorded — a graph the library never saw. The caller owns it; honour the close.
454
+ - The child recorded elsewhere — the inherited sink, or one :func:`_mark_inherited` marked
455
+ ``_FOREIGN``. Refused however it was reached, which is what closes the wrapper route
456
+ (FR-002 AC-3).
457
+ - The wrapper recorded, the child not — the sink added to a wrapper *after* ``configure()``
458
+ walked it. Refused, per FR-001 AC-6: the library holds this graph, so a member it has no
459
+ record of is one it must not assume is this process's. The consequence is a leak, recorded
460
+ in §13.
461
+
462
+ **"Unrecorded is the caller's" is only sound because a fork makes it false first.** In a
463
+ child, :func:`_mark_inherited` records everything inherited as ``_FOREIGN`` *before* any
464
+ handler runs, so an unrecorded sink there is one built after the fork. If that walk could
465
+ not finish, :data:`_marking_failed` withdraws the assumption entirely and every unrecorded
466
+ sink is refused.
467
+
468
+ Identity is re-checked against the strong reference rather than trusting the ``id``. The
469
+ reference is what makes an id collision impossible while a record stands, so this can only
470
+ fail if that invariant breaks — and answering ``False`` there is the safe direction.
471
+
472
+ Args:
473
+ sink: The sink a caller is about to close.
474
+ owner: The wrapper forwarding the close, when one is. ``None`` from the three lifecycle
475
+ sites, which hold the sink directly.
476
+
477
+ Returns:
478
+ Whether this process may close it.
479
+
480
+ Raises:
481
+ None.
482
+ """
483
+ pid = os.getpid()
484
+ with _owned_lock:
485
+ record = _owned.get(id(sink))
486
+ owner_record = None if owner is None else _owned.get(id(owner))
487
+ if record is not None:
488
+ return record[1] is sink and record[0] == pid
489
+ return owner_record is None and not _marking_failed
490
+
491
+
492
+ def release(sink: Sink, *, detached: bool = False, owner: object = None) -> threading.Thread | None:
493
+ """Closes a sink on the library's behalf — the one path by which it ever does (SPEC-042 FR-002).
494
+
495
+ Eight sites closed a sink directly before this existed, and guarding only the three the
496
+ lifecycle owns was measurably insufficient: a forked child that wraps an **inherited** sink in
497
+ a ``MultiSink`` of its own reaches the inner sink through the wrapper, so the parent's
498
+ structural sink was closed twice with all three lifecycle sites guarded. Routing every
499
+ library closer through one function is what gives the ownership question one home.
500
+
501
+ **The guard moves here; the error handling does not.** This propagates whatever ``close()``
502
+ raises, because the callers do not agree today and must not be made to: four absorb, under
503
+ three distinct ``_diag`` texts naming the site (SPEC-029), ``MultiSink`` also increments its
504
+ ``failed`` counter, and ``FilteringSink``/``TransformSink``/``LogstashSink`` propagate under
505
+ a documented ``Raises:``. Folding the ``try/except`` in here would drop absorbed close
506
+ failures out of ``Health.sink.failed`` — a SPEC-026 regression — and falsify those three.
507
+
508
+ **A sink this process did not acquire is skipped, not failed** (FR-001, FR-002). A forked
509
+ child inherits the parent's sink object, and closing it sends a real protocol goodbye on a
510
+ connection the parent is still using — measured, the parent's next write failed with
511
+ ``ECONNRESET``. Refusing here is a **skip**: nothing is counted as lost, nothing is retried,
512
+ and every caller's control flow is unchanged, so ``MultiSink.close`` still isolates and
513
+ continues, ``shutdown()`` still returns, and a swap still installs its new sink. The sink is
514
+ left **open**, which is the trade SPEC-027 FR-004 and SPEC-030 already made twice: a leaked
515
+ resource in an exiting process beats a corrupt write.
516
+
517
+ Only the *release* is refused. Every **drain** is untouched (FR-003), so a child still gets
518
+ its own events out through the sink it inherited.
519
+
520
+ Args:
521
+ sink: The sink to close.
522
+ detached: Whether to close on a daemon thread rather than inline. Detached is for a sink
523
+ the caller has stopped delivering to and must not block on (SPEC-030 FR-003).
524
+ owner: The wrapper forwarding this close, when one is. The five shipped wrappers pass
525
+ themselves; the three lifecycle sites hold their sink directly and pass nothing. See
526
+ :func:`releasable` for what the distinction decides.
527
+
528
+ Returns:
529
+ The started closer thread for a detached release, or ``None`` — for a refused release, for
530
+ a completed inline close, and when the platform would not give the process another thread.
531
+ A caller needing to tell those apart consults :func:`releasable`, which it already has.
532
+
533
+ Raises:
534
+ Exception: Whatever an inline ``close()`` raised. A refused release raises nothing, and
535
+ neither does a detached one: its thread body absorbs, since there is no caller left to
536
+ hand it to.
537
+ """
538
+ if not releasable(sink, owner=owner):
539
+ return None
540
+ if detached:
541
+ return _start_closer(sink)
542
+ sink.close()
543
+ return None
544
+
545
+
546
+ def _start_closer(sink: Sink) -> threading.Thread | None:
547
+ """Starts a daemon close of a sink no longer being delivered to (SPEC-030 FR-003).
548
+
549
+ The thread is returned rather than joined, so a caller holding a lock can start under it and
550
+ wait after releasing it — ``decorator._swap_sink`` mutates its records under the process-wide
551
+ ``_worker_lock`` and must not hold that across a wait of the swap's whole budget (SPEC-033
552
+ FR-002). Callers that hold no lock join it immediately and are equivalent to the single call
553
+ this replaced.
554
+
555
+ The thread is a **daemon**, and it is :func:`join_closers` that makes that safe rather than
556
+ merely available. A non-daemon thread was tried and is worse on its own: CPython joins
557
+ non-daemon threads *before* running ``atexit``, so one hung close stops the exit drain from
558
+ ever running and loses everything buffered in the **live** sink. A daemon alone is worse in
559
+ the opposite case: a close that is slow but *succeeding* is killed at exit, losing whatever
560
+ it was flushing.
561
+
562
+ Args:
563
+ sink: The sink that was swapped out.
564
+
565
+ Returns:
566
+ The started thread, or ``None`` when the platform would not give the process another one.
567
+
568
+ Raises:
569
+ None. ``Thread.start`` raises when the process is out of threads, and a swap that cannot
570
+ spawn one must leave the sink open and say so rather than fall back to an inline close —
571
+ the fallback would reintroduce the unbounded wait this exists to remove, in the one
572
+ situation where the process is already under resource pressure.
573
+ """
574
+ closer = threading.Thread(
575
+ target=_close_guarded,
576
+ args=(sink,),
577
+ name="log-foundry-sink-close",
578
+ daemon=True,
579
+ )
580
+ try:
581
+ closer.start()
582
+ except Exception as exc:
583
+ _diag.absorbed(
584
+ "starting the thread that closes a swapped-out sink",
585
+ exc,
586
+ "it is left open and may still hold its resources",
587
+ )
588
+ return None
589
+ with _closers_lock:
590
+ _closers[:] = [old for old in _closers if old.is_alive()]
591
+ _closers.append(closer)
592
+ return closer
593
+
594
+
595
+ def _close_guarded(sink: Sink) -> None:
596
+ """Closes a swapped-out sink on its own thread, absorbing a failure.
597
+
598
+ The guard is what makes the thread safe to leave unattended: an exception escaping here
599
+ would reach CPython's thread bootstrap, which prints a full traceback carrying the
600
+ exception's message — the user data arch §6 keeps out of anything the library says about
601
+ itself. It goes back through :func:`release` rather than calling ``close()`` itself, so the
602
+ thread body is one of the eight callers rather than a ninth close (SPEC-042 FR-002).
603
+
604
+ Args:
605
+ sink: The sink to close.
606
+
607
+ Returns:
608
+ None.
609
+
610
+ Raises:
611
+ None.
612
+ """
613
+ try:
614
+ release(sink)
615
+ except Exception as exc:
616
+ _diag.absorbed("closing a swapped-out sink", exc, "it may still hold its resources")
617
+
618
+
619
+ def join_closers(timeout: float | None) -> None:
620
+ """Gives outstanding swapped-out closes their last chance before the process exits.
621
+
622
+ **The cap is the mechanism.** The wait is the smaller of :data:`DEFAULT_CLOSER_GRACE` and
623
+ what remains of the shutdown's own budget: capped so a stuck close cannot hold a process at
624
+ exit for the whole shutdown budget, and carved from that budget so it cannot extend it either.
625
+
626
+ The registry is process-global rather than per-worker because a close started before any
627
+ worker existed must still be counted and still be granted this grace (SPEC-033 FR-005).
628
+
629
+ Args:
630
+ timeout: Seconds remaining in the shutdown's budget, further capped by
631
+ :data:`DEFAULT_CLOSER_GRACE` and shared across every outstanding close. ``None`` takes
632
+ the cap rather than waiting indefinitely — an unbounded shutdown is a caller's choice
633
+ about draining events, not a licence for a stuck close to hold the exit.
634
+
635
+ Returns:
636
+ None.
637
+
638
+ Raises:
639
+ None. A join on a thread that has already finished is a no-op, and one that has not is
640
+ abandoned at the deadline — which is the daemon's contract, not a failure.
641
+ """
642
+ with _closers_lock:
643
+ closers = [closer for closer in _closers if closer.is_alive()]
644
+ _closers[:] = closers
645
+ grace = DEFAULT_CLOSER_GRACE if timeout is None else min(timeout, DEFAULT_CLOSER_GRACE)
646
+ deadline = time.monotonic() + grace
647
+ for closer in closers:
648
+ closer.join(max(0.0, deadline - time.monotonic()))
649
+
650
+
651
+ def closing_count() -> int:
652
+ """Counts the swapped-out closes running at this instant, backing ``Health.closing_sinks``.
653
+
654
+ A live fact rather than an inference from a timeout: an expired join reports nothing, since
655
+ a slow close and a stuck one cannot be told apart at that moment, so this gauge is what an
656
+ operator reads instead. It falls as well as rises.
657
+
658
+ Args:
659
+ None.
660
+
661
+ Returns:
662
+ The number of closer threads still alive.
663
+
664
+ Raises:
665
+ None.
666
+ """
667
+ with _closers_lock:
668
+ _closers[:] = [closer for closer in _closers if closer.is_alive()]
669
+ return len(_closers)
670
+
671
+
672
+ def offer_stop_signal(sink: Sink, stop: threading.Event) -> None:
673
+ """Gives a sink an interruptible-wait signal, if it advertises somewhere to put one.
674
+
675
+ The dependency stays one-way (SPEC-027 FR-002): ``sinks`` must not import ``worker``, so the
676
+ holder of the event pushes rather than the sink pulling. It is probed with ``hasattr``, the
677
+ same optional-protocol shape SPEC-026 uses for ``losses()`` — a sink without the attribute
678
+ simply never gets one and backs off uninterruptibly, exactly as before.
679
+
680
+ Args:
681
+ sink: The sink to offer the signal to.
682
+ stop: The event that is set when delivery should stop waiting.
683
+
684
+ Returns:
685
+ None.
686
+
687
+ Raises:
688
+ None. A sink whose ``log_foundry_stop_signal`` is a read-only property, or whose
689
+ ``__setattr__``
690
+ objects, loses interruptibility rather than preventing the caller from proceeding.
691
+ """
692
+ try:
693
+ if hasattr(sink, "log_foundry_stop_signal"):
694
+ sink.log_foundry_stop_signal = stop
695
+ except Exception as exc:
696
+ _diag.absorbed("handing the sink its stop signal", exc, "its backoff stays uninterruptible")
697
+
698
+
699
+ _fork.register_child_handler(_mark_inherited)
@@ -6,6 +6,8 @@ import threading
6
6
  from dataclasses import dataclass, field, replace
7
7
  from typing import TYPE_CHECKING
8
8
 
9
+ from log_foundry import _lifecycle
10
+
9
11
  if TYPE_CHECKING:
10
12
  from log_foundry.sinks.base import Sink
11
13
 
@@ -169,6 +171,9 @@ def configure(
169
171
  )
170
172
  if value is not None
171
173
  }
174
+ if sink is not None:
175
+ _lifecycle.stamp(sink)
176
+
172
177
  _rebind(**changed)
173
178
 
174
179
  _ensure_sink()
@@ -316,7 +321,9 @@ def _ensure_sink() -> Sink:
316
321
 
317
322
  with _config_lock:
318
323
  if _config.sink is None:
319
- _config = replace(_config, sink=StdoutSink())
324
+ default = StdoutSink()
325
+ _lifecycle.stamp(default)
326
+ _config = replace(_config, sink=default)
320
327
  resolved = _config.sink
321
328
  if resolved is None: # pragma: no cover - unreachable; the branch above just set it
322
329
  raise RuntimeError("the default sink could not be resolved")
@@ -185,4 +185,4 @@ class FilteringSink:
185
185
  Raises:
186
186
  Exception: Whatever the inner sink raises on close.
187
187
  """
188
- _lifecycle.release(self._inner)
188
+ _lifecycle.release(self._inner, owner=self)
@@ -140,7 +140,7 @@ class LogstashSink:
140
140
  Exception: Whatever the backend raises on close.
141
141
  """
142
142
  if self._http is not None:
143
- _lifecycle.release(self._http)
143
+ _lifecycle.release(self._http, owner=self)
144
144
  elif self._socket is not None:
145
145
  self._socket.close()
146
146
 
@@ -195,7 +195,7 @@ class MultiSink:
195
195
  """
196
196
  for sink in self._sinks:
197
197
  try:
198
- _lifecycle.release(sink)
198
+ _lifecycle.release(sink, owner=self)
199
199
  except Exception as err:
200
200
  with self._counter_lock:
201
201
  self.failed += 1
@@ -142,7 +142,7 @@ class SentrySink:
142
142
  None.
143
143
  """
144
144
  if self._http is not None:
145
- _lifecycle.release(self._http)
145
+ _lifecycle.release(self._http, owner=self)
146
146
 
147
147
  @property
148
148
  def log_foundry_stop_signal(self) -> threading.Event | None:
@@ -153,4 +153,4 @@ class TransformSink:
153
153
  Raises:
154
154
  Exception: Whatever the inner sink raises on close.
155
155
  """
156
- _lifecycle.release(self._inner)
156
+ _lifecycle.release(self._inner, owner=self)
@@ -1,212 +0,0 @@
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 release(sink: Sink, *, detached: bool = False) -> threading.Thread | None:
28
- """Closes a sink on the library's behalf — the one path by which it ever does (SPEC-042 FR-002).
29
-
30
- Eight sites closed a sink directly before this existed, and guarding only the three the
31
- lifecycle owns was measurably insufficient: a forked child that wraps an **inherited** sink in
32
- a ``MultiSink`` of its own reaches the inner sink through the wrapper, so the parent's
33
- structural sink was closed twice with all three lifecycle sites guarded. Routing every
34
- library closer through one function is what gives the ownership question one home.
35
-
36
- **The guard moves here; the error handling does not.** This propagates whatever ``close()``
37
- raises, because the callers do not agree today and must not be made to: four absorb, under
38
- three distinct ``_diag`` texts naming the site (SPEC-029), ``MultiSink`` also increments its
39
- ``failed`` counter, and ``FilteringSink``/``TransformSink``/``LogstashSink`` propagate under
40
- a documented ``Raises:``. Folding the ``try/except`` in here would drop absorbed close
41
- failures out of ``Health.sink.failed`` — a SPEC-026 regression — and falsify those three.
42
-
43
- Args:
44
- sink: The sink to close.
45
- detached: Whether to close on a daemon thread rather than inline. Detached is for a sink
46
- the caller has stopped delivering to and must not block on (SPEC-030 FR-003).
47
-
48
- Returns:
49
- The started closer thread for a detached release, or ``None`` — both when an inline close
50
- completed and when the platform would not give the process another thread.
51
-
52
- Raises:
53
- Exception: Whatever an inline ``close()`` raised. A detached release raises nothing: the
54
- thread body absorbs, since there is no caller left to hand it to.
55
- """
56
- if detached:
57
- return _start_closer(sink)
58
- sink.close()
59
- return None
60
-
61
-
62
- def _start_closer(sink: Sink) -> threading.Thread | None:
63
- """Starts a daemon close of a sink no longer being delivered to (SPEC-030 FR-003).
64
-
65
- The thread is returned rather than joined, so a caller holding a lock can start under it and
66
- wait after releasing it — ``decorator._swap_sink`` mutates its records under the process-wide
67
- ``_worker_lock`` and must not hold that across a wait of the swap's whole budget (SPEC-033
68
- FR-002). Callers that hold no lock join it immediately and are equivalent to the single call
69
- this replaced.
70
-
71
- The thread is a **daemon**, and it is :func:`join_closers` that makes that safe rather than
72
- merely available. A non-daemon thread was tried and is worse on its own: CPython joins
73
- non-daemon threads *before* running ``atexit``, so one hung close stops the exit drain from
74
- ever running and loses everything buffered in the **live** sink. A daemon alone is worse in
75
- the opposite case: a close that is slow but *succeeding* is killed at exit, losing whatever
76
- it was flushing.
77
-
78
- Args:
79
- sink: The sink that was swapped out.
80
-
81
- Returns:
82
- The started thread, or ``None`` when the platform would not give the process another one.
83
-
84
- Raises:
85
- None. ``Thread.start`` raises when the process is out of threads, and a swap that cannot
86
- spawn one must leave the sink open and say so rather than fall back to an inline close —
87
- the fallback would reintroduce the unbounded wait this exists to remove, in the one
88
- situation where the process is already under resource pressure.
89
- """
90
- closer = threading.Thread(
91
- target=_close_guarded,
92
- args=(sink,),
93
- name="log-foundry-sink-close",
94
- daemon=True,
95
- )
96
- try:
97
- closer.start()
98
- except Exception as exc:
99
- _diag.absorbed(
100
- "starting the thread that closes a swapped-out sink",
101
- exc,
102
- "it is left open and may still hold its resources",
103
- )
104
- return None
105
- with _closers_lock:
106
- _closers[:] = [old for old in _closers if old.is_alive()]
107
- _closers.append(closer)
108
- return closer
109
-
110
-
111
- def _close_guarded(sink: Sink) -> None:
112
- """Closes a swapped-out sink on its own thread, absorbing a failure.
113
-
114
- The guard is what makes the thread safe to leave unattended: an exception escaping here
115
- would reach CPython's thread bootstrap, which prints a full traceback carrying the
116
- exception's message — the user data arch §6 keeps out of anything the library says about
117
- itself. It goes back through :func:`release` rather than calling ``close()`` itself, so the
118
- thread body is one of the eight callers rather than a ninth close (SPEC-042 FR-002).
119
-
120
- Args:
121
- sink: The sink to close.
122
-
123
- Returns:
124
- None.
125
-
126
- Raises:
127
- None.
128
- """
129
- try:
130
- release(sink)
131
- except Exception as exc:
132
- _diag.absorbed("closing a swapped-out sink", exc, "it may still hold its resources")
133
-
134
-
135
- def join_closers(timeout: float | None) -> None:
136
- """Gives outstanding swapped-out closes their last chance before the process exits.
137
-
138
- **The cap is the mechanism.** The wait is the smaller of :data:`DEFAULT_CLOSER_GRACE` and
139
- what remains of the shutdown's own budget: capped so a stuck close cannot hold a process at
140
- exit for the whole shutdown budget, and carved from that budget so it cannot extend it either.
141
-
142
- The registry is process-global rather than per-worker because a close started before any
143
- worker existed must still be counted and still be granted this grace (SPEC-033 FR-005).
144
-
145
- Args:
146
- timeout: Seconds remaining in the shutdown's budget, further capped by
147
- :data:`DEFAULT_CLOSER_GRACE` and shared across every outstanding close. ``None`` takes
148
- the cap rather than waiting indefinitely — an unbounded shutdown is a caller's choice
149
- about draining events, not a licence for a stuck close to hold the exit.
150
-
151
- Returns:
152
- None.
153
-
154
- Raises:
155
- None. A join on a thread that has already finished is a no-op, and one that has not is
156
- abandoned at the deadline — which is the daemon's contract, not a failure.
157
- """
158
- with _closers_lock:
159
- closers = [closer for closer in _closers if closer.is_alive()]
160
- _closers[:] = closers
161
- grace = DEFAULT_CLOSER_GRACE if timeout is None else min(timeout, DEFAULT_CLOSER_GRACE)
162
- deadline = time.monotonic() + grace
163
- for closer in closers:
164
- closer.join(max(0.0, deadline - time.monotonic()))
165
-
166
-
167
- def closing_count() -> int:
168
- """Counts the swapped-out closes running at this instant, backing ``Health.closing_sinks``.
169
-
170
- A live fact rather than an inference from a timeout: an expired join reports nothing, since
171
- a slow close and a stuck one cannot be told apart at that moment, so this gauge is what an
172
- operator reads instead. It falls as well as rises.
173
-
174
- Args:
175
- None.
176
-
177
- Returns:
178
- The number of closer threads still alive.
179
-
180
- Raises:
181
- None.
182
- """
183
- with _closers_lock:
184
- _closers[:] = [closer for closer in _closers if closer.is_alive()]
185
- return len(_closers)
186
-
187
-
188
- def offer_stop_signal(sink: Sink, stop: threading.Event) -> None:
189
- """Gives a sink an interruptible-wait signal, if it advertises somewhere to put one.
190
-
191
- The dependency stays one-way (SPEC-027 FR-002): ``sinks`` must not import ``worker``, so the
192
- holder of the event pushes rather than the sink pulling. It is probed with ``hasattr``, the
193
- same optional-protocol shape SPEC-026 uses for ``losses()`` — a sink without the attribute
194
- simply never gets one and backs off uninterruptibly, exactly as before.
195
-
196
- Args:
197
- sink: The sink to offer the signal to.
198
- stop: The event that is set when delivery should stop waiting.
199
-
200
- Returns:
201
- None.
202
-
203
- Raises:
204
- None. A sink whose ``log_foundry_stop_signal`` is a read-only property, or whose
205
- ``__setattr__``
206
- objects, loses interruptibility rather than preventing the caller from proceeding.
207
- """
208
- try:
209
- if hasattr(sink, "log_foundry_stop_signal"):
210
- sink.log_foundry_stop_signal = stop
211
- except Exception as exc:
212
- _diag.absorbed("handing the sink its stop signal", exc, "its backoff stays uninterruptible")