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.
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/PKG-INFO +1 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/_fork.py +40 -0
- log_foundry-0.10.2.dev62/src/log_foundry/_lifecycle.py +699 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/config.py +8 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/filtering.py +1 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/logstash.py +1 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/multi.py +1 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sentry.py +1 -1
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/transform.py +1 -1
- log_foundry-0.10.2.dev61/src/log_foundry/_lifecycle.py +0 -212
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/LICENSE +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/README.md +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/__init__.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/decorator.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/results.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/memory.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/null.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/worker.py +0 -0
|
@@ -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
|
-
|
|
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")
|
|
@@ -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
|
|
|
@@ -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")
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{log_foundry-0.10.2.dev61 → log_foundry-0.10.2.dev62}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|