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