log-foundry 0.10.2.dev52__tar.gz → 0.10.2.dev54__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.dev52 → log_foundry-0.10.2.dev54}/PKG-INFO +1 -1
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/pyproject.toml +1 -1
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/__init__.py +3 -0
- log_foundry-0.10.2.dev54/src/log_foundry/_fork.py +478 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/decorator.py +38 -1
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/worker.py +100 -1
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/LICENSE +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/README.md +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/_diag.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/_lifecycle.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/api.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/config.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/console.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/context.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/ids.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/model.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/py.typed +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/results.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sanitize.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/__init__.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/_batch.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/_chunk.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/_retry.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/_socket.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/_time.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/base.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/callback.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/clickhouse.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/datadog.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/elasticsearch.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/eventhubs.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/file.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/filtering.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/firehose.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/honeycomb.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/http.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/kafka.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/kinesis.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/logging_sink.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/logstash.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/loki.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/memory.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/mongodb.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/multi.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/nats.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/newrelic.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/null.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/postgres.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/pubsub.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/rabbitmq.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/redis.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/sentry.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/sns.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/splunk.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/sqlite.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/sqs.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/stdout.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/syslog.py +0 -0
- {log_foundry-0.10.2.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/transform.py +0 -0
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from importlib.metadata import PackageNotFoundError
|
|
4
4
|
from importlib.metadata import version as _dist_version
|
|
5
5
|
|
|
6
|
+
from log_foundry import _fork
|
|
6
7
|
from log_foundry.api import critical, debug, error, info, set_baggage, warning
|
|
7
8
|
from log_foundry.config import Config, configure, get_config
|
|
8
9
|
from log_foundry.context import (
|
|
@@ -22,6 +23,8 @@ try:
|
|
|
22
23
|
except PackageNotFoundError:
|
|
23
24
|
__version__ = "0.0.0"
|
|
24
25
|
|
|
26
|
+
_fork.install()
|
|
27
|
+
|
|
25
28
|
|
|
26
29
|
def flush(timeout: float | None = 5.0) -> FlushResult:
|
|
27
30
|
"""Drains buffered events through the sink without closing it.
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""Repairing the library's own synchronization state in a forked child (SPEC-039)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import collections
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import threading
|
|
9
|
+
import types
|
|
10
|
+
from typing import TYPE_CHECKING, Any
|
|
11
|
+
|
|
12
|
+
from log_foundry import _diag
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
|
|
17
|
+
_PACKAGE = __name__.rpartition(".")[0]
|
|
18
|
+
"""This package's import name, derived rather than written.
|
|
19
|
+
|
|
20
|
+
A literal is wrong under a vendored install — ``myapp._vendor.log_foundry`` — where both the
|
|
21
|
+
root selection and the ownership test would miss every module and the handler would silently
|
|
22
|
+
repair nothing.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
_RLOCK_TYPE = type(threading.RLock())
|
|
26
|
+
|
|
27
|
+
_LOCK_TYPES: tuple[type, ...] = (type(threading.Lock()), _RLOCK_TYPE)
|
|
28
|
+
|
|
29
|
+
_CONTAINER_TYPES: tuple[type, ...] = (list, tuple, set, frozenset, dict, collections.deque)
|
|
30
|
+
|
|
31
|
+
_child_handlers: list[Callable[[], None]] = []
|
|
32
|
+
"""What runs in the child once its locks are its own again, in registration order.
|
|
33
|
+
|
|
34
|
+
**An inverted registry, and that is what keeps the dependency arrow pointing one way**
|
|
35
|
+
(FR-006). The work a child needs done belongs to ``decorator``, ``_lifecycle`` and the sinks,
|
|
36
|
+
and all three would have to import this module for it to reach them; instead they register with
|
|
37
|
+
it, so this module imports nothing that imports it and there is no cycle to break later.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
_installed = False
|
|
41
|
+
"""Whether :func:`install` has already registered the child handler in this process.
|
|
42
|
+
|
|
43
|
+
A flag and a function rather than a bare module-scope call, so that registration is one
|
|
44
|
+
statement a reader can find and repeated calls cannot stack handlers (FR-006 AC-2). What it
|
|
45
|
+
does **not** cover is stated rather than implied: ``importlib.reload`` re-runs this body and
|
|
46
|
+
resets the flag, so a deliberate reload registers a second handler. That is harmless — the
|
|
47
|
+
repair is idempotent and a child would simply run it twice — and closing it would mean
|
|
48
|
+
recording the registration somewhere outside this module, which is a worse trade than saying so.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _defined_here(cls: type) -> bool:
|
|
53
|
+
"""Whether one class was defined in this package.
|
|
54
|
+
|
|
55
|
+
Args:
|
|
56
|
+
cls: The class to place.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Whether its defining module is this package or a module inside it.
|
|
60
|
+
|
|
61
|
+
Raises:
|
|
62
|
+
None.
|
|
63
|
+
"""
|
|
64
|
+
module = getattr(cls, "__module__", "") or ""
|
|
65
|
+
return module == _PACKAGE or module.startswith(f"{_PACKAGE}.")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_owned(value: object) -> bool:
|
|
69
|
+
"""Whether this object runs code this package defines, anywhere in its ancestry.
|
|
70
|
+
|
|
71
|
+
The ownership test is what keeps the traversal off third-party state (FR-003 AC-2): a
|
|
72
|
+
``boto3`` session's locks, a ``librdkafka`` handle and a ``psycopg`` connection are not the
|
|
73
|
+
library's to swap, and reaching into them would be a fork fix that breaks a driver.
|
|
74
|
+
|
|
75
|
+
**The whole MRO is asked, not just the defining module**, because subclassing a shipped sink
|
|
76
|
+
is a documented extension point — ``README`` offers ``Sink`` to subclass and SPEC-038 rebuilt
|
|
77
|
+
``HTTPSink.emit`` as a template method for exactly that. An instance of a user's
|
|
78
|
+
``class MySink(FileSink)`` reports ``__main__``, so a defining-module test walked straight
|
|
79
|
+
past a ``_lock`` that ``FileSink.__init__`` built: measured, the child hung in ``info()``
|
|
80
|
+
while a plain ``FileSink`` in the same probe returned. That is the hang this spec exists to
|
|
81
|
+
remove, reached through the one door users are told to use.
|
|
82
|
+
|
|
83
|
+
What that widening costs is recorded rather than left to be discovered: a class **mixing in**
|
|
84
|
+
a third-party base alongside a library one has its foreign attributes replaced too, measured
|
|
85
|
+
on ``class MySink(FileSink, ThirdPartyBase)``. A separately *held* client is still untouched,
|
|
86
|
+
which is the boundary FR-005 states — the two cannot be told apart from the instance, and
|
|
87
|
+
refusing the mixed case would mean refusing the sink's own lock with it.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
value: Any object, including a class.
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Whether it or any of its ancestors was defined in this package.
|
|
94
|
+
|
|
95
|
+
Raises:
|
|
96
|
+
None.
|
|
97
|
+
"""
|
|
98
|
+
owner = value if isinstance(value, type) else type(value)
|
|
99
|
+
return any(_defined_here(base) for base in getattr(owner, "__mro__", (owner,)))
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _is_container(value: object) -> bool:
|
|
103
|
+
"""Whether the walk reads this value's members as well as its attributes.
|
|
104
|
+
|
|
105
|
+
``isinstance`` rather than an exact-type test, so a ``deque``, a ``defaultdict`` or any
|
|
106
|
+
other ordinary subclass is entered: an exact tuple made membership depend on which concrete
|
|
107
|
+
class a future sink happened to hold its children in, which is the guess FR-003 AC-3 exists
|
|
108
|
+
to replace.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
value: Any object.
|
|
112
|
+
|
|
113
|
+
Returns:
|
|
114
|
+
Whether it holds members the walk should read.
|
|
115
|
+
|
|
116
|
+
Raises:
|
|
117
|
+
None.
|
|
118
|
+
"""
|
|
119
|
+
return isinstance(value, _CONTAINER_TYPES)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _is_traversable(value: object) -> bool:
|
|
123
|
+
"""Whether the walk descends into this value.
|
|
124
|
+
|
|
125
|
+
Three shapes and no others: a module of this package, an object running code this package
|
|
126
|
+
defines, and a plain container, which is traversed because ``MultiSink._sinks`` is one and
|
|
127
|
+
the sinks inside it hold the locks the child's first log call takes.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
value: Any object.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Whether it is a namespace or container the walk should enter.
|
|
134
|
+
|
|
135
|
+
Raises:
|
|
136
|
+
None.
|
|
137
|
+
"""
|
|
138
|
+
if isinstance(value, types.ModuleType):
|
|
139
|
+
name = getattr(value, "__name__", "") or ""
|
|
140
|
+
return name == _PACKAGE or name.startswith(f"{_PACKAGE}.")
|
|
141
|
+
return _is_container(value) or _is_owned(value)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _container_children(container: Any) -> list[Any]:
|
|
145
|
+
"""Returns what a plain container holds, keys included for a mapping.
|
|
146
|
+
|
|
147
|
+
Nothing is *replaced* inside a container: a primitive there would be unreachable in a
|
|
148
|
+
tuple or a set, so a partial answer would read as coverage it does not have. The AST lint
|
|
149
|
+
for FR-003 AC-3 forbids that shape outright instead, which is what makes descent-only
|
|
150
|
+
correct here rather than merely convenient.
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
container: Any value :func:`_is_container` accepted.
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Its members, or both its keys and its values for a mapping.
|
|
157
|
+
|
|
158
|
+
Raises:
|
|
159
|
+
None. A container mutating under the walk would raise, and a child that cannot finish
|
|
160
|
+
repairing itself must still repair what it reached. A foreign container subclass
|
|
161
|
+
reachable from an owned attribute runs its own code here — ``keys``/``values`` for a
|
|
162
|
+
mapping, ``__iter__`` for anything else — which is absorbed if it raises, but a
|
|
163
|
+
*blocking* one is a child that never returns from ``fork`` and there is nothing to catch
|
|
164
|
+
that with.
|
|
165
|
+
"""
|
|
166
|
+
try:
|
|
167
|
+
if isinstance(container, dict):
|
|
168
|
+
return [*container.keys(), *container.values()]
|
|
169
|
+
return list(container)
|
|
170
|
+
except Exception as exc:
|
|
171
|
+
_diag.absorbed("reading a container after a fork", exc, "what it holds is not repaired")
|
|
172
|
+
return []
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _slot_names(holder: object) -> list[str]:
|
|
176
|
+
"""Returns every ``__slots__`` name declared across a holder's class hierarchy.
|
|
177
|
+
|
|
178
|
+
A slotted instance keeps its attributes off ``__dict__``, so a walk reading only ``vars()``
|
|
179
|
+
would miss them. No shipped class needs this today and that is stated rather than dressed
|
|
180
|
+
up: ``worker._FlushMarker`` is slotted and holds an ``Event``, but a marker lives inside a
|
|
181
|
+
``queue.Queue`` the walk never enters. It is here because the shape lint accepts a slotted
|
|
182
|
+
``self.<attr>``, so the walk has to be able to reach one.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
holder: Any object.
|
|
186
|
+
|
|
187
|
+
Returns:
|
|
188
|
+
The declared slot names, which may be empty.
|
|
189
|
+
|
|
190
|
+
Raises:
|
|
191
|
+
None.
|
|
192
|
+
"""
|
|
193
|
+
names: list[str] = []
|
|
194
|
+
for cls in type(holder).__mro__:
|
|
195
|
+
declared = cls.__dict__.get("__slots__", ())
|
|
196
|
+
if isinstance(declared, str):
|
|
197
|
+
names.append(declared)
|
|
198
|
+
else:
|
|
199
|
+
names.extend(str(name) for name in declared)
|
|
200
|
+
return names
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _namespace_items(holder: Any) -> list[tuple[str, Any]]:
|
|
204
|
+
"""Returns the ``(name, value)`` pairs a holder owns, without triggering its properties.
|
|
205
|
+
|
|
206
|
+
Values come from the instance ``__dict__`` and the slot descriptors rather than from a
|
|
207
|
+
blanket ``getattr`` over ``dir()``, which would evaluate every property — including ones
|
|
208
|
+
that open a connection or take the very lock this is about to replace.
|
|
209
|
+
|
|
210
|
+
Args:
|
|
211
|
+
holder: A module, a class, or an instance.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
One pair per attribute the holder itself carries.
|
|
215
|
+
|
|
216
|
+
Raises:
|
|
217
|
+
None. An attribute that cannot be read is skipped, since a repair that stops at the first
|
|
218
|
+
awkward object leaves the rest of the process holding dead locks.
|
|
219
|
+
"""
|
|
220
|
+
items: list[tuple[str, Any]] = []
|
|
221
|
+
try:
|
|
222
|
+
own = dict(vars(holder))
|
|
223
|
+
except TypeError:
|
|
224
|
+
own = {}
|
|
225
|
+
except Exception as exc:
|
|
226
|
+
_diag.absorbed("reading an object's attributes after a fork", exc, "it is not repaired")
|
|
227
|
+
return []
|
|
228
|
+
items.extend(own.items())
|
|
229
|
+
if isinstance(holder, types.ModuleType | type):
|
|
230
|
+
return items
|
|
231
|
+
for name in _slot_names(holder):
|
|
232
|
+
if name in own:
|
|
233
|
+
continue
|
|
234
|
+
value = _slot_value(holder, name)
|
|
235
|
+
if value is not None:
|
|
236
|
+
items.append((name, value))
|
|
237
|
+
return items
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _slot_value(holder: object, name: str) -> Any | None:
|
|
241
|
+
"""Reads one slot, answering ``None`` for a slot that is unset or refuses to be read.
|
|
242
|
+
|
|
243
|
+
Both answers are deliberately the same, because both mean "there is nothing here to
|
|
244
|
+
replace": a lock is never ``None``, so nothing is lost by conflating them, and an object
|
|
245
|
+
that raises on attribute access must not end the repair for the rest of the process.
|
|
246
|
+
|
|
247
|
+
Args:
|
|
248
|
+
holder: The instance to read from.
|
|
249
|
+
name: The slot name.
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
The value, or ``None``.
|
|
253
|
+
|
|
254
|
+
Raises:
|
|
255
|
+
None.
|
|
256
|
+
"""
|
|
257
|
+
try:
|
|
258
|
+
return getattr(holder, name, None)
|
|
259
|
+
except Exception:
|
|
260
|
+
return None
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _assign(holder: Any, name: str, value: Any) -> None:
|
|
264
|
+
"""Puts a fresh primitive where the dead one was.
|
|
265
|
+
|
|
266
|
+
Instances are written through ``object.__setattr__`` so a frozen dataclass or a custom
|
|
267
|
+
``__setattr__`` cannot refuse the repair; modules and classes take the ordinary path,
|
|
268
|
+
which is the only one they have.
|
|
269
|
+
|
|
270
|
+
Args:
|
|
271
|
+
holder: The module, class or instance carrying the attribute.
|
|
272
|
+
name: The attribute to rebind.
|
|
273
|
+
value: The replacement primitive.
|
|
274
|
+
|
|
275
|
+
Returns:
|
|
276
|
+
None.
|
|
277
|
+
|
|
278
|
+
Raises:
|
|
279
|
+
None. A holder that refuses the write keeps a primitive no thread can ever release, which
|
|
280
|
+
is announced rather than raised: this runs in a child that has not yet returned from
|
|
281
|
+
``fork``.
|
|
282
|
+
"""
|
|
283
|
+
try:
|
|
284
|
+
if isinstance(holder, types.ModuleType | type):
|
|
285
|
+
setattr(holder, name, value)
|
|
286
|
+
else:
|
|
287
|
+
object.__setattr__(holder, name, value)
|
|
288
|
+
except Exception as exc:
|
|
289
|
+
_diag.absorbed(
|
|
290
|
+
"re-initialising a lock after a fork",
|
|
291
|
+
exc,
|
|
292
|
+
f"{type(holder).__name__}.{name} may block the next caller forever",
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def _fresh_primitive(value: Any, memo: dict[int, Any], keepalive: list[Any]) -> Any | None:
|
|
297
|
+
"""Returns the replacement for one lock or event, minting it at most once.
|
|
298
|
+
|
|
299
|
+
**The memo is load-bearing, not tidiness.** A sink's ``log_foundry_stop_signal`` *is* the
|
|
300
|
+
worker's ``_stop`` (SPEC-027), so two fresh events would leave the worker setting one and
|
|
301
|
+
the sink waiting on the other — a shutdown that never cuts a backoff short. An ``Event``
|
|
302
|
+
carries its set state across, which is also what makes replacing one safe at all.
|
|
303
|
+
|
|
304
|
+
Args:
|
|
305
|
+
value: The attribute value under inspection.
|
|
306
|
+
memo: Replacements already minted, keyed by the id of what they replace.
|
|
307
|
+
keepalive: Holds every replaced primitive, so no id in ``memo`` can be reused by a later
|
|
308
|
+
object and hand back the wrong replacement.
|
|
309
|
+
|
|
310
|
+
Returns:
|
|
311
|
+
The replacement, or ``None`` when this value is not a lock or an event.
|
|
312
|
+
|
|
313
|
+
Raises:
|
|
314
|
+
None.
|
|
315
|
+
"""
|
|
316
|
+
existing = memo.get(id(value))
|
|
317
|
+
if existing is not None:
|
|
318
|
+
return existing
|
|
319
|
+
fresh: Any
|
|
320
|
+
if isinstance(value, threading.Event):
|
|
321
|
+
fresh = threading.Event()
|
|
322
|
+
if value.is_set():
|
|
323
|
+
fresh.set()
|
|
324
|
+
elif isinstance(value, _LOCK_TYPES):
|
|
325
|
+
fresh = threading.RLock() if isinstance(value, _RLOCK_TYPE) else threading.Lock()
|
|
326
|
+
else:
|
|
327
|
+
return None
|
|
328
|
+
memo[id(value)] = fresh
|
|
329
|
+
keepalive.append(value)
|
|
330
|
+
return fresh
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def _reinit_primitives() -> None:
|
|
334
|
+
"""Replaces every lock and event this package owns, wherever the walk reaches one.
|
|
335
|
+
|
|
336
|
+
An inherited ``Lock`` stays locked with no owner — measured, ``acquire(timeout=1)`` returns
|
|
337
|
+
``False`` — so a child's first log call blocks forever on the application's own thread. A
|
|
338
|
+
lock that was *not* held is replaced too: asking whether one is held has no answer that is
|
|
339
|
+
not itself a race (FR-003 AC-6).
|
|
340
|
+
|
|
341
|
+
Being a container and being a namespace are **not** exclusive: an owned class that
|
|
342
|
+
subclasses one holds both members and attributes, and treating the two as alternatives
|
|
343
|
+
silently drops whichever branch lost. The cost is proportional to what the library's own
|
|
344
|
+
containers hold, which for a buffering sink is caller data — a ``MemorySink`` holding 100k
|
|
345
|
+
events measured 202 ms, against 0.45 ms idle. That is accepted rather than bounded: a cap
|
|
346
|
+
would be a lock this cannot promise to find, and the alternative to finding it is a hang.
|
|
347
|
+
|
|
348
|
+
Args:
|
|
349
|
+
None.
|
|
350
|
+
|
|
351
|
+
Returns:
|
|
352
|
+
None.
|
|
353
|
+
|
|
354
|
+
Raises:
|
|
355
|
+
None.
|
|
356
|
+
"""
|
|
357
|
+
memo: dict[int, Any] = {}
|
|
358
|
+
keepalive: list[Any] = []
|
|
359
|
+
seen: set[int] = set()
|
|
360
|
+
stack: list[Any] = [
|
|
361
|
+
module
|
|
362
|
+
for name, module in list(sys.modules.items())
|
|
363
|
+
if module is not None and (name == _PACKAGE or name.startswith(f"{_PACKAGE}."))
|
|
364
|
+
]
|
|
365
|
+
while stack:
|
|
366
|
+
holder = stack.pop()
|
|
367
|
+
if id(holder) in seen:
|
|
368
|
+
continue
|
|
369
|
+
seen.add(id(holder))
|
|
370
|
+
if _is_container(holder):
|
|
371
|
+
stack.extend(child for child in _container_children(holder) if _is_traversable(child))
|
|
372
|
+
if not isinstance(holder, types.ModuleType | type) and not _is_owned(holder):
|
|
373
|
+
continue
|
|
374
|
+
for name, value in _namespace_items(holder):
|
|
375
|
+
fresh = _fresh_primitive(value, memo, keepalive)
|
|
376
|
+
if fresh is not None:
|
|
377
|
+
_assign(holder, name, fresh)
|
|
378
|
+
elif _is_traversable(value):
|
|
379
|
+
stack.append(value)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def register_child_handler(fn: Callable[[], None]) -> None:
|
|
383
|
+
"""Adds work to be done in a forked child, after its locks have been re-initialised.
|
|
384
|
+
|
|
385
|
+
A handler may take any of the library's locks, which is what the ordering in
|
|
386
|
+
:func:`_reinit_after_fork` buys it and why registering is the only way in. What it must not
|
|
387
|
+
assume is that it is alone: handlers run in registration order and an earlier one may have
|
|
388
|
+
started a thread — ``decorator``'s rebuild does exactly that — so only the *first* runs in a
|
|
389
|
+
genuinely single-threaded child. None of them may block, since nothing has returned from
|
|
390
|
+
``fork`` yet.
|
|
391
|
+
|
|
392
|
+
Registering the same function twice is a no-op, compared by **identity**: ``in`` would ask
|
|
393
|
+
a registered object's ``__eq__``, which is not this function's to trust and would raise into
|
|
394
|
+
a caller documented as raising nothing. The exposure is the one :data:`_installed` records
|
|
395
|
+
for the fork registration itself: a reload of a module whose body registers here would
|
|
396
|
+
otherwise stack a second handler, and for the worker rebuild that means two drain threads,
|
|
397
|
+
the first bound to a queue nothing writes to.
|
|
398
|
+
|
|
399
|
+
Args:
|
|
400
|
+
fn: Called with no arguments in the child. A failure is absorbed and announced, and the
|
|
401
|
+
remaining handlers still run.
|
|
402
|
+
|
|
403
|
+
Returns:
|
|
404
|
+
None.
|
|
405
|
+
|
|
406
|
+
Raises:
|
|
407
|
+
None.
|
|
408
|
+
"""
|
|
409
|
+
if not any(handler is fn for handler in _child_handlers):
|
|
410
|
+
_child_handlers.append(fn)
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
def _reinit_after_fork() -> None:
|
|
414
|
+
"""Repairs the library in a child that has just returned from ``fork``.
|
|
415
|
+
|
|
416
|
+
**The order of work here is the contract** (FR-001 AC-2): locks and events first, then the
|
|
417
|
+
registered handlers. A lock re-initialised *after* a handler that takes it is a handler
|
|
418
|
+
that hangs, and it hangs on the child's only thread with nothing to interrupt it. Work that
|
|
419
|
+
must happen before *any* handler belongs inline between the two steps rather than registered
|
|
420
|
+
— the buffer discard FR-004 adds is the case, and registering it would put it after
|
|
421
|
+
``decorator``'s rebuild, which has started a live drain thread by then.
|
|
422
|
+
|
|
423
|
+
Args:
|
|
424
|
+
None.
|
|
425
|
+
|
|
426
|
+
Returns:
|
|
427
|
+
None.
|
|
428
|
+
|
|
429
|
+
Raises:
|
|
430
|
+
None. A fork handler that raises has its exception printed by CPython with a full
|
|
431
|
+
traceback, carrying the message arch §6 keeps out of anything the library says about
|
|
432
|
+
itself — and it would leave the rest of the repair undone. One handler's failure is
|
|
433
|
+
absorbed separately from the rest for the same reason: a child that cannot rebuild its
|
|
434
|
+
worker should still have working locks. The list is iterated live rather than copied,
|
|
435
|
+
which is safe because CPython's list iterator is index-based, so a handler that
|
|
436
|
+
registers another simply causes it to run — and :func:`register_child_handler` is the
|
|
437
|
+
only writer, appending only.
|
|
438
|
+
"""
|
|
439
|
+
try:
|
|
440
|
+
_reinit_primitives()
|
|
441
|
+
except Exception as exc:
|
|
442
|
+
_diag.absorbed("repairing the library after a fork", exc, "this child may block or lose")
|
|
443
|
+
for handler in _child_handlers:
|
|
444
|
+
try:
|
|
445
|
+
handler()
|
|
446
|
+
except Exception as exc:
|
|
447
|
+
_diag.absorbed("running a fork handler", exc, "this child may not deliver")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def install() -> None:
|
|
451
|
+
"""Registers the child handler with ``os.register_at_fork``, once per process.
|
|
452
|
+
|
|
453
|
+
Called from the package's ``__init__`` so registration happens at import of the package,
|
|
454
|
+
and idempotent across repeated calls, which together are what make a double import register
|
|
455
|
+
once (FR-006 AC-2 — see :data:`_installed` for the one case that is not covered).
|
|
456
|
+
**Only** ``after_in_child`` is registered (FR-001 AC-1):
|
|
457
|
+
``before`` does not run for a C-level fork at all — uWSGI calls ``PyOS_AfterFork_Child``
|
|
458
|
+
only — so the child handler has to be sufficient regardless, and a parent-side handler
|
|
459
|
+
would buy a partial fix for a measured 1.20 s hold on the forking thread.
|
|
460
|
+
|
|
461
|
+
A platform without ``os.register_at_fork`` — Windows — imports the package cleanly and
|
|
462
|
+
registers nothing, which is what the guard is for. Nothing else in the library changes
|
|
463
|
+
behaviour there, since only ``fork`` inherits the hazards this closes.
|
|
464
|
+
|
|
465
|
+
Args:
|
|
466
|
+
None.
|
|
467
|
+
|
|
468
|
+
Returns:
|
|
469
|
+
None.
|
|
470
|
+
|
|
471
|
+
Raises:
|
|
472
|
+
None.
|
|
473
|
+
"""
|
|
474
|
+
global _installed
|
|
475
|
+
if _installed or not hasattr(os, "register_at_fork"):
|
|
476
|
+
return
|
|
477
|
+
_installed = True
|
|
478
|
+
os.register_at_fork(after_in_child=_reinit_after_fork)
|
|
@@ -11,7 +11,7 @@ from dataclasses import replace
|
|
|
11
11
|
from time import monotonic
|
|
12
12
|
from typing import TYPE_CHECKING, Any, TypeVar, cast, overload
|
|
13
13
|
|
|
14
|
-
from log_foundry import _diag, _lifecycle, context
|
|
14
|
+
from log_foundry import _diag, _fork, _lifecycle, context
|
|
15
15
|
from log_foundry.config import _ensure_sink
|
|
16
16
|
from log_foundry.ids import (
|
|
17
17
|
is_valid_span_id,
|
|
@@ -332,6 +332,43 @@ def _note_orphan_emit(sink: Sink) -> None:
|
|
|
332
332
|
_orphan_sink = sink
|
|
333
333
|
|
|
334
334
|
|
|
335
|
+
def _rebuild_worker_after_fork() -> None:
|
|
336
|
+
"""Gives a forked child a drain thread of its own, or a retired worker (SPEC-039 FR-002).
|
|
337
|
+
|
|
338
|
+
Registered with ``_fork`` at import rather than reached for by it, which is what keeps that
|
|
339
|
+
module free of an import of this one (FR-006). It runs on the child's only thread, after the
|
|
340
|
+
locks are its own again, so it may take them.
|
|
341
|
+
|
|
342
|
+
The predicate is deliberately **not** ``_live_worker()``, even though the two agree today.
|
|
343
|
+
What is being asked is whether to start a thread, and the answer must survive a later
|
|
344
|
+
reading of ``retired`` — so it is hoisted into ``resume`` and passed, where the roster files
|
|
345
|
+
it as its own decision rather than as a call whose category a reader has to chase.
|
|
346
|
+
|
|
347
|
+
``_worker_lock`` is deliberately **not** taken. There is one thread here by construction,
|
|
348
|
+
and the lock was re-initialised moments ago: a child that took it would be taking a lock no
|
|
349
|
+
other thread can contend for, at the one moment in the process's life when that is provably
|
|
350
|
+
true.
|
|
351
|
+
|
|
352
|
+
Args:
|
|
353
|
+
None.
|
|
354
|
+
|
|
355
|
+
Returns:
|
|
356
|
+
None.
|
|
357
|
+
|
|
358
|
+
Raises:
|
|
359
|
+
None. ``_fork`` absorbs and announces a handler's failure, so a child whose worker cannot
|
|
360
|
+
be rebuilt still has working locks.
|
|
361
|
+
"""
|
|
362
|
+
worker = _worker
|
|
363
|
+
if worker is None:
|
|
364
|
+
return
|
|
365
|
+
resume = not worker.retired
|
|
366
|
+
worker._reinit_after_fork(resume=resume)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
_fork.register_child_handler(_rebuild_worker_after_fork)
|
|
370
|
+
|
|
371
|
+
|
|
335
372
|
def _live_worker() -> Worker | None:
|
|
336
373
|
"""Returns the process worker only while it is still delivering (SPEC-033 FR-002).
|
|
337
374
|
|
|
@@ -81,7 +81,10 @@ class Health:
|
|
|
81
81
|
report. Non-``None`` is categorically worse than the two counters above: they measure
|
|
82
82
|
loss the worker absorbed and kept running through, this one means the worker is gone
|
|
83
83
|
(SPEC-019 FR-003). Also ``"ShutdownTimeout"`` when a bounded :meth:`Worker.shutdown`
|
|
84
|
-
expired before the drain finished (SPEC-027 FR-004), the
|
|
84
|
+
expired before the drain finished (SPEC-027 FR-004), and the type name of whatever
|
|
85
|
+
stopped a forked child's rebuild from starting a thread at all (SPEC-039 FR-002) — a
|
|
86
|
+
case where the drain thread never ran rather than died. All three are the same thing to
|
|
87
|
+
a reader, which is why they share the field: nothing is delivering.
|
|
85
88
|
sink: The configured sink's own loss counters, or ``None`` when there is no worker or
|
|
86
89
|
the sink reports nothing (SPEC-026 FR-003). Nested rather than folded into the
|
|
87
90
|
integers above because they count different things: ``dropped`` here is backpressure
|
|
@@ -229,6 +232,7 @@ class Worker:
|
|
|
229
232
|
self.stopped_reason: str | None = None
|
|
230
233
|
self.submitted_after_shutdown = 0
|
|
231
234
|
self.incomplete_swaps = 0
|
|
235
|
+
self._max_queue = max_queue
|
|
232
236
|
self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
|
|
233
237
|
self._stop = threading.Event()
|
|
234
238
|
self._drain_finished = threading.Event()
|
|
@@ -242,6 +246,101 @@ class Worker:
|
|
|
242
246
|
)
|
|
243
247
|
self._thread.start()
|
|
244
248
|
|
|
249
|
+
def _reinit_after_fork(self, *, resume: bool) -> None:
|
|
250
|
+
"""Rebuilds this worker **in place** for a child that has just forked (SPEC-039 FR-002).
|
|
251
|
+
|
|
252
|
+
The child inherits a ``Worker`` whose thread does not exist, so ``submit`` goes on
|
|
253
|
+
enqueueing and nothing drains: measured, six events never delivered, ``atexit`` closing
|
|
254
|
+
the sink without a drain, and ``health()`` reading ``queued=2`` with every other field
|
|
255
|
+
clean — the documented alert idiom blind. Rebuilding rather than retiring is the design:
|
|
256
|
+
a prefork server's child is a working process, and a child that silently stops logging
|
|
257
|
+
is the failure this arc exists to remove.
|
|
258
|
+
|
|
259
|
+
**In place, never a new object** (FR-002 AC-5). The ownership guards keyed on
|
|
260
|
+
``_worker.sink is X`` and ``_lifecycle``'s registry are identity comparisons, so
|
|
261
|
+
replacing the worker would leave every one of them answering about something else.
|
|
262
|
+
|
|
263
|
+
**The queue is replaced, not drained.** Emptying it would keep ``queue.Queue``'s own
|
|
264
|
+
mutex and its three ``Condition``s, which the fork walk cannot reach — it enters no
|
|
265
|
+
standard-library container — and a fork landing inside that mutex leaves the child's
|
|
266
|
+
very next ``submit`` blocked on the application's thread. That is why the replacement
|
|
267
|
+
happens even when nothing is resumed: a retired worker still *accepts* submissions
|
|
268
|
+
(SPEC-030 FR-001), so a retired child would block on the same mutex. Starting empty is
|
|
269
|
+
also what stops the parent's undelivered backlog being sent twice (AC-2).
|
|
270
|
+
|
|
271
|
+
``stopped_reason`` is cleared rather than set to ``"Forked"`` (AC-6). SPEC-019 defines
|
|
272
|
+
that field as "the drain thread died", and this child's drain thread is about to be
|
|
273
|
+
running — the alternative reads as the more honest one and is not.
|
|
274
|
+
|
|
275
|
+
The two drain events are **set or cleared to match what this child will actually do**,
|
|
276
|
+
never simply inherited. Resuming clears them, so ``draining`` and ``flush``'s gate
|
|
277
|
+
describe the thread starting here rather than the one that did not survive the fork;
|
|
278
|
+
retiring **sets** them, because no thread will ever set them and a child forked while a
|
|
279
|
+
``shutdown()`` was mid-join otherwise inherits them unset with nothing to settle them.
|
|
280
|
+
Two consequences, both measured: that child paid the whole 30 s budget at exit and with
|
|
281
|
+
``shutdown(timeout=None)`` would never exit at all, and — reaching further —
|
|
282
|
+
``_offer_orphan_signal`` reads it as still draining and skips, leaving the sink holding
|
|
283
|
+
a **set** stop event, so every later backoff returns instantly against a destination
|
|
284
|
+
that is already refusing (SPEC-033 FR-004). ``_drain_finished`` has no reader on a
|
|
285
|
+
retired worker and is set for the invariant rather than for an observable: settled with
|
|
286
|
+
finished clear is what :attr:`draining` defines as an *abandoned* drain, and a child
|
|
287
|
+
reporting no ``stopped_reason`` must not read that way. The stop signal is re-offered for
|
|
288
|
+
the sink the fork walk cannot reach: a **third-party** sink is outside its ownership
|
|
289
|
+
boundary, so it would keep pointing at the pre-fork event while this worker sets a new
|
|
290
|
+
one — SPEC-027's guarantee broken by the repair meant to preserve it.
|
|
291
|
+
|
|
292
|
+
**The new thread is only installed once it has started**, which ``__init__`` never had
|
|
293
|
+
to consider: a constructor whose ``start`` raises lets no ``Worker`` escape, while here
|
|
294
|
+
the worker is already the process's. Assigning first would leave an unstarted ``Thread``
|
|
295
|
+
on a live worker reading ``draining`` forever, and the next ``shutdown()`` would take a
|
|
296
|
+
``RuntimeError`` from ``join`` straight out of a public call documented to raise nothing.
|
|
297
|
+
The inherited thread object is kept instead, which is safe because CPython repairs it
|
|
298
|
+
across the fork — measured, it reports dead and both a bounded and an unbounded ``join``
|
|
299
|
+
return in 0.0000 s — and the failure is recorded as a ``stopped_reason``, which is
|
|
300
|
+
exactly SPEC-019's vocabulary for "nothing is delivering". Setting both drain events is
|
|
301
|
+
what keeps :meth:`shutdown` from queueing a sentinel into a queue no thread will read.
|
|
302
|
+
|
|
303
|
+
The success path assigns ``self._thread`` *after* the start, so for an instant a live
|
|
304
|
+
drain thread coexists with the inherited dead one in that attribute. That is safe only
|
|
305
|
+
because the drain thread never reads it — ``_run``, ``_drain``, ``_terminal_failure``
|
|
306
|
+
and ``_release_waiters`` do not, and the three readers are all on caller threads — which
|
|
307
|
+
is an invariant this note states rather than one anything enforces.
|
|
308
|
+
|
|
309
|
+
Args:
|
|
310
|
+
resume: Whether to start a drain thread. ``False`` for a retired parent, which forks
|
|
311
|
+
a retired child (AC-4): a fork does not undo a ``shutdown()``, and reviving a worker
|
|
312
|
+
the caller terminated would be the library overruling them.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
None.
|
|
316
|
+
|
|
317
|
+
Raises:
|
|
318
|
+
None.
|
|
319
|
+
"""
|
|
320
|
+
self._queue = queue.Queue(maxsize=self._max_queue)
|
|
321
|
+
self.dropped = 0
|
|
322
|
+
self.failed_batches = 0
|
|
323
|
+
self.submitted_after_shutdown = 0
|
|
324
|
+
self.incomplete_swaps = 0
|
|
325
|
+
self.stopped_reason = None
|
|
326
|
+
if not resume:
|
|
327
|
+
self._drain_finished.set()
|
|
328
|
+
self._drain_settled.set()
|
|
329
|
+
return
|
|
330
|
+
self._drain_finished.clear()
|
|
331
|
+
self._drain_settled.clear()
|
|
332
|
+
self._offer_stop_signal()
|
|
333
|
+
thread = threading.Thread(target=self._run, name="log-foundry-worker", daemon=True)
|
|
334
|
+
try:
|
|
335
|
+
thread.start()
|
|
336
|
+
except Exception as exc:
|
|
337
|
+
self.stopped_reason = type(exc).__name__
|
|
338
|
+
self._drain_finished.set()
|
|
339
|
+
self._drain_settled.set()
|
|
340
|
+
_diag.absorbed("starting this child's drain thread", exc, "it will deliver nothing")
|
|
341
|
+
return
|
|
342
|
+
self._thread = thread
|
|
343
|
+
|
|
245
344
|
def _offer_stop_signal(self) -> None:
|
|
246
345
|
"""Gives the sink this worker's shutdown event, if it advertises somewhere to put it.
|
|
247
346
|
|
|
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.dev52 → log_foundry-0.10.2.dev54}/src/log_foundry/sinks/elasticsearch.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|